commit a06448ca4b3e9314551f71df3098b043ba4c52a4 Author: Whyverum Date: Tue Feb 17 11:24:55 2026 +0700 Первый коммит diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52c1e73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Исключить скрытые системные каталоги, но не всё подряд +.git/ +.github/ +.gitlab-ci.yml +.gitattributes +.gitignore +LICENSE + +# Виртуальные окружения и Python-кэш +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.pyo + +# IDE-файлы +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Тесты и документация +tests/ +test/ +doc/ +docs/ +examples/ +README.md +_count.py +*.md +pytest.ini +.pytest_cache/ + +# Логи и артефакты сборки +*.log +*.logs +*.log.* +*.logs.* +Logs/ +Log/ +dist/ +build/ + +# Примеры и шаблоны +.env +env +.env_example + + +pyproject.toml +poetry.lock diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..c2311c2 --- /dev/null +++ b/.env_example @@ -0,0 +1,253 @@ +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ КОНФИГУРАЦИЯ TELEGRAM БОТА ║ +# ║ ║ +# ║ Инструкция: ║ +# ║ 1. Скопируйте этот файл и переименуйте в .env ║ +# ║ 2. Заполните обязательные поля (отмечены [ОБЯЗАТЕЛЬНО]) ║ +# ║ 3. Настройте опциональные параметры по необходимости ║ +# ║ ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + + + +# ═══════════════════════════════════════════════════════════════════════════ +# МИНИМАЛЬНАЯ КОНФИГУРАЦИЯ (только обязательные поля) +# ═══════════════════════════════════════════════════════════════════════════ + +# [ОБЯЗАТЕЛЬНО] Токен бота от @BotFather +BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz1234567890 + +# [ОБЯЗАТЕЛЬНО] ID владельцев (узнать: @userinfobot) +OWNER_ID=123456789 + +# [ОБЯЗАТЕЛЬНО] ID админского чата (узнать: @username_to_id_bot) +ADMIN_CHAT_ID=-1001234567890 + + + +# ═══════════════════════════════════════════════════════════════════════════ +# 🤖 ТОКЕН БОТА +# ═══════════════════════════════════════════════════════════════════════════ + +# [ОБЯЗАТЕЛЬНО] Токен бота от @BotFather +# Как получить: отправьте /newbot боту @BotFather и следуйте инструкциям +BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz1234567890 + + +# ═══════════════════════════════════════════════════════════════════════════ +# 👤 АДМИНИСТРАТОРЫ И ID +# ═══════════════════════════════════════════════════════════════════════════ + +# [ОБЯЗАТЕЛЬНО] ID владельцев бота (список через запятую) +# Как узнать свой ID: отправьте сообщение боту @userinfobot +OWNER_ID=123456789,987654321 + +# [ОБЯЗАТЕЛЬНО] ID чата для уведомлений админов (куда бот будет слать логи о спаме) +# Для группы: добавьте бота в группу и используйте бота @username_to_id_bot +ADMIN_CHAT_ID=-1001234567890 + +# Дополнительные админы (не суперадмины, но имеют доступ к командам) +# Необязательно, можно оставить пустым +ADMIN_ID=111111111,222222222 + + +# ═══════════════════════════════════════════════════════════════════════════ +# ⚙️ ОСНОВНЫЕ НАСТРОЙКИ БОТА +# ═══════════════════════════════════════════════════════════════════════════ + +# Имя бота (отображается в /start и описании) +BOT_NAME=Первозданная Жемчужина + +# Описание бота (длинное, до 512 символов) +# Если не указано, будет сгенерировано автоматически +# BOT_DESCRIPTION=Бот-модератор для защиты чата от спама и нецензурных слов + +# Короткое описание (до 120 символов) +# Если не указано, будет сгенерировано автоматически +# BOT_SHORT_DESCRIPTION=Тех.поддержка: @verdise + +# Путь к фото профиля бота (необязательно) +# BOT_PHOTO=./assets/bot_avatar.jpg + + +# ═══════════════════════════════════════════════════════════════════════════ +# 💬 НАСТРОЙКИ СООБЩЕНИЙ +# ═══════════════════════════════════════════════════════════════════════════ + +# Режим разметки текста (HTML, Markdown, MarkdownV2) +PARSE_MODE=HTML + +# Префиксы команд (символы, которые могут начинать команды) +PREFIX=/!.&? + +# Отключить звуковые уведомления при отправке сообщений ботом +DISABLE_NOTIFICATION=false + +# Защитить контент от пересылки и сохранения +PROTECT_CONTENT=false + +# Разрешить отправку без ответа на реплай +ALLOW_SENDING_WITHOUT_REPLY=true + +# Настройки превью ссылок +LINK_PREVIEW_IS_DISABLED=false +LINK_PREVIEW_PREFER_SMALL_MEDIA=false +LINK_PREVIEW_PREFER_LARGE_MEDIA=true +LINK_PREVIEW_SHOW_ABOVE_TEXT=true + +# Показывать подпись над медиа +SHOW_CAPTION_ABOVE_MEDIA=false + + +# ═══════════════════════════════════════════════════════════════════════════ +# 📝 ЛОГИРОВАНИЕ +# ═══════════════════════════════════════════════════════════════════════════ + +# Включить логи в консоль +LOG_CONSOLE=true + +# Включить логи в файл +LOG_FILE=true + +# Директория для логов +LOG_DIR=Logs + +# Имя файла с общей информацией +LOG_FILE_INFO=bot_info.log + +# Ротация логов (размер файла для создания нового) +# Примеры: 100 MB, 500 MB, 1 GB +LOG_ROTATION=100 MB + +# Время хранения старых логов +# Примеры: 7 days, 30 days, 1 week, 1 month +LOG_RETENTION=7 days + +# Выводить информацию о старте в консоль +START_INFO_CONSOLE=true + +# Выводить информацию о старте в файл +START_INFO_TO_FILE=true + + +# ═══════════════════════════════════════════════════════════════════════════ +# 🌐 WEBHOOK (опционально) +# ═══════════════════════════════════════════════════════════════════════════ + +# Использовать вебхук вместо long polling +# false = long polling (рекомендуется для начинающих) +# true = webhook (требует публичный HTTPS домен) +WEBHOOK=false + +# URL вебхука (обязателен если WEBHOOK=true) +# Должен быть публичный HTTPS адрес +# WEBHOOK_URL=https://your-domain.com/webhook + +# Секретный токен для вебхука (генерируется автоматически если не указан) +# SECRET_TOKEN=your-secret-token-here + +# Хост для uvicorn (внутренний адрес сервера) +WEBAPP_HOST=0.0.0.0 + +# Порт для uvicorn +WEBAPP_PORT=3131 + +# Уровень логов для uvicorn (debug, info, warning, error, critical) +LOG_LEVEL=warning + +# Включить access log для uvicorn +ACCES_LOG=false + + +# ═══════════════════════════════════════════════════════════════════════════ +# 🔑 API КЛЮЧИ (опционально) +# ═══════════════════════════════════════════════════════════════════════════ + +# Различные API ключи для интеграций +# Заполните только те, которые используете + +# Общий API ключ +# API_KEY=your-api-key-here + +# Web API ключ +# WEB_API_KEY=your-web-api-key-here + +# API ключ для погоды (например, OpenWeatherMap) +# WEATHER_API_KEY=your-weather-api-key-here + + +# ═══════════════════════════════════════════════════════════════════════════ +# 👮 ПРАВА АДМИНИСТРАТОРА БОТА +# ═══════════════════════════════════════════════════════════════════════════ + +# Разрешить боту редактировать свой профиль при старте +BOT_EDIT=false + +# Анонимный администратор +ANONYMOUS=false + +# Управление чатом +MANAGE_CHAT=true + +# Изменение информации о чате +CHANGE_INFO=true + +# Повышение участников +PROMOTE_MEMBERS=true + +# Ограничение участников (бан, мут) +RESTRICT_MEMBERS=true + +# Публикация сообщений (для каналов) +POST_MESSAGE=true + +# Управление темами (топиками) +MANAGE_TOPICS=true + +# Приглашение пользователей +INVITE_USER=true + +# Удаление сообщений +DELETE_MESSAGES=true + +# Управление видеочатами +MANAGE_VIDEO_CHATS=true + +# Редактирование сообщений (для каналов) +EDIT_MESSAGES=true + +# Закрепление сообщений +PIN_MESSAGE=true + +# Публикация историй +POST_STORIES=true + +# Редактирование историй +EDIT_STORIES=true + +# Удаление историй +DELETE_STORIES=true + + +# ═══════════════════════════════════════════════════════════════════════════ +# 🚫 МОДЕРАТОР (банворды и фильтры) +# ═══════════════════════════════════════════════════════════════════════════ + +# Файл с запрещенными словами (JSON) +WORDS_FILE=banwords.json + +# Директория для постов +POSTS_DIR=posts + + +# ═══════════════════════════════════════════════════════════════════════════ +# 📌 ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ +# ═══════════════════════════════════════════════════════════════════════════ + +# Добавьте сюда свои кастомные переменные по необходимости +# Например: + +# DATABASE_URL=postgresql://user:password@localhost/dbname +# REDIS_URL=redis://localhost:6379 +# MAX_WARNINGS=3 +# BAN_DURATION=86400 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a679a5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,97 @@ +# ============================================================================= +# Git LFS: большие бинарные файлы, модели, архивы +# ============================================================================= +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text + +# ============================================================================= +# Автоопределение текста, окончания строк +# ============================================================================= +* text=auto eol=lf + +# ============================================================================= +# Текстовые файлы (Python, конфиги, документы) +# ============================================================================= +*.py text +*.pyi text +*.ipynb text +*.html text +*.css text +*.js text +*.json text +*.md text +*.yml text +*.yaml text +*.xml text +*.txt text +*.cfg text +*.toml text +*.ini text +*.env text + +# ============================================================================= +# Изображения +# ============================================================================= +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.webp binary +*.ico binary +*.svg text + +# ============================================================================= +# Шрифты +# ============================================================================= +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary + +# ============================================================================= +# GitHub Linguist (указание языка для отображения) +# ============================================================================= +*.py linguist-language=Python +*.ipynb linguist-language=Jupyter Notebook +*.html linguist-language=HTML +*.css linguist-language=CSS +*.js linguist-language=JavaScript +*.json linguist-language=JSON +*.md linguist-language=Markdown +*.yml linguist-language=YAML +*.yaml linguist-language=YAML diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcdd9e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# .gitignore: Игнорируемые файлы для Python проектов +# Подробнее: https://github.com/github/gitignore/blob/main/Python.gitignore + +### Python ### +# Виртуальные окружения и настройки +*.venv +*.env +venv/ +env/ + +# Кэш интерпретатора +__pycache__/ +*.py[cod] +*$py.class + +# Пакеты и сборки +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.eg +*.egg +*.eggs + +# Poetry +poetry.lock +.pypoetry/ + +### Логи и БД ### +*.log +*.logs +*.log.* +*.logs.* +log/ +logs/ +*.sqlite +*.db + +### IDE ### +.idea/ +.vscode/ +*.swp +*.sublime-* + +### OS ### +.DS_Store +Thumbs.db + +### Тестирование ### +.coverage +htmlcov/ +.tox/ +.nox/ +.pytest_cache/ +.mypy_cache/ +test/ +tests/ +_count.py + + +requirements.txt diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PrimoGuardBot.iml b/.idea/PrimoGuardBot.iml new file mode 100644 index 0000000..af007a6 --- /dev/null +++ b/.idea/PrimoGuardBot.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7c04147 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..abb29b8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89e5c61 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Базовый образ Python +FROM python:3.13-slim + +# Рабочая директория +WORKDIR /app + +# Копируем requirements.txt +COPY requirements.txt . + +# Устанавливаем зависимости +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем все файлы проекта +COPY . . + +# Запускаем бота +CMD ["python", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..825ccc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2026] [Verum] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e963fd8 Binary files /dev/null and b/README.md differ diff --git a/assets/photo/default.jpg b/assets/photo/default.jpg new file mode 100644 index 0000000..a024412 Binary files /dev/null and b/assets/photo/default.jpg differ diff --git a/assets/photo/start.jpg b/assets/photo/start.jpg new file mode 100644 index 0000000..a2d9984 Binary files /dev/null and b/assets/photo/start.jpg differ diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..bfe8a87 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,4 @@ +from .core import * +from .handlers import router +from .middlewares import * +from .filters import * diff --git a/bot/core/__init__.py b/bot/core/__init__.py new file mode 100644 index 0000000..a3d3c93 --- /dev/null +++ b/bot/core/__init__.py @@ -0,0 +1,5 @@ +""" +Модуль управления ботом +""" +from .bots import * +from .webhook import * diff --git a/bot/core/bots.py b/bot/core/bots.py new file mode 100644 index 0000000..4090df2 --- /dev/null +++ b/bot/core/bots.py @@ -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() + diff --git a/bot/core/webhook.py b/bot/core/webhook.py new file mode 100644 index 0000000..616ceb1 --- /dev/null +++ b/bot/core/webhook.py @@ -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 + ) diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py new file mode 100644 index 0000000..8c63804 --- /dev/null +++ b/bot/filters/__init__.py @@ -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 * diff --git a/bot/filters/admin.py b/bot/filters/admin.py new file mode 100644 index 0000000..d5cfe00 --- /dev/null +++ b/bot/filters/admin.py @@ -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 diff --git a/bot/filters/callback.py b/bot/filters/callback.py new file mode 100644 index 0000000..ad52fdc --- /dev/null +++ b/bot/filters/callback.py @@ -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 diff --git a/bot/filters/chat_rights.py b/bot/filters/chat_rights.py new file mode 100644 index 0000000..f98b03a --- /dev/null +++ b/bot/filters/chat_rights.py @@ -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 diff --git a/bot/filters/chat_type.py b/bot/filters/chat_type.py new file mode 100644 index 0000000..45bbef3 --- /dev/null +++ b/bot/filters/chat_type.py @@ -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) diff --git a/bot/filters/modes.py b/bot/filters/modes.py new file mode 100644 index 0000000..fbc3248 --- /dev/null +++ b/bot/filters/modes.py @@ -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 diff --git a/bot/filters/msg_content.py b/bot/filters/msg_content.py new file mode 100644 index 0000000..548e8d5 --- /dev/null +++ b/bot/filters/msg_content.py @@ -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 diff --git a/bot/filters/spam.py b/bot/filters/spam.py new file mode 100644 index 0000000..9722ab8 --- /dev/null +++ b/bot/filters/spam.py @@ -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 diff --git a/bot/filters/subscription.py b/bot/filters/subscription.py new file mode 100644 index 0000000..3158efe --- /dev/null +++ b/bot/filters/subscription.py @@ -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 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..ea860be --- /dev/null +++ b/bot/handlers/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/__init__.py b/bot/handlers/commands/__init__.py new file mode 100644 index 0000000..f8baa9f --- /dev/null +++ b/bot/handlers/commands/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/admins/__init__.py b/bot/handlers/commands/admins/__init__.py new file mode 100644 index 0000000..fec40b1 --- /dev/null +++ b/bot/handlers/commands/admins/__init__.py @@ -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, + +) diff --git a/bot/handlers/commands/admins/all_cmd.py b/bot/handlers/commands/admins/all_cmd.py new file mode 100644 index 0000000..b7807a0 --- /dev/null +++ b/bot/handlers/commands/admins/all_cmd.py @@ -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}") diff --git a/bot/handlers/commands/admins/ban_cmd.py b/bot/handlers/commands/admins/ban_cmd.py new file mode 100644 index 0000000..9e5f369 --- /dev/null +++ b/bot/handlers/commands/admins/ban_cmd.py @@ -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 или ответ на сообщение пользователя + /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 " + ) + 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 " + ) + 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("❌ Не удалось получить список забаненных пользователей") diff --git a/bot/handlers/commands/admins/kick_cmd.py b/bot/handlers/commands/admins/kick_cmd.py new file mode 100644 index 0000000..e0fcadb --- /dev/null +++ b/bot/handlers/commands/admins/kick_cmd.py @@ -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 или ответ на сообщение пользователя + /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 " + ) + 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 или ответ на сообщение пользователя + /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 " + ) + 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 + +**💥 /kick_ban** - Кикнуть пользователя с удалением сообщений +• Ответьте на сообщение пользователя с командой /kick_ban +• Или используйте: /kick_ban + +**🚫 /ban** - Полностью забанить пользователя +**🔓 /unban** - Разбанить пользователя +**📋 /banned_list** - Список забаненных + +⚠️ *Команды работают только в группах и требуют прав администратора* + """ + + await message.answer(help_text, parse_mode=None) diff --git a/bot/handlers/commands/admins/pin_cmd.py b/bot/handlers/commands/admins/pin_cmd.py new file mode 100644 index 0000000..63e29d4 --- /dev/null +++ b/bot/handlers/commands/admins/pin_cmd.py @@ -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) diff --git a/bot/handlers/commands/admins/settings_cmd.py b/bot/handlers/commands/admins/settings_cmd.py new file mode 100644 index 0000000..7131f13 --- /dev/null +++ b/bot/handlers/commands/admins/settings_cmd.py @@ -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 = _( + """Добро пожаловать, {name}! + +Я ваш искусственный помощник по ролевой - {rp_name}! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! + +Интересный факт: +
{fact}
+""" + ).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) diff --git a/bot/handlers/commands/settings/__init__.py b/bot/handlers/commands/settings/__init__.py new file mode 100644 index 0000000..af7d05f --- /dev/null +++ b/bot/handlers/commands/settings/__init__.py @@ -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, +) \ No newline at end of file diff --git a/bot/handlers/commands/settings/set_description_cmd.py b/bot/handlers/commands/settings/set_description_cmd.py new file mode 100644 index 0000000..ebcca9c --- /dev/null +++ b/bot/handlers/commands/settings/set_description_cmd.py @@ -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=_("✅ Короткое описание бота успешно изменено на: {description}").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Попробуйте снова через: {retry_text}").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 при изменении короткого описания:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + state=state + ) + + except Exception as e: + logger.error(f"Непредвиденная ошибка при изменении короткого описания: {e}") + await msg( + update=message, + text=_("❌ Непредвиденная ошибка при изменении короткого описания:
{error}
").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 = _( + "📝 Смена короткого описания бота\n\n" + "Текущее короткое описание: {current}\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) diff --git a/bot/handlers/commands/settings/set_name_cmd.py b/bot/handlers/commands/settings/set_name_cmd.py new file mode 100644 index 0000000..1cb5b99 --- /dev/null +++ b/bot/handlers/commands/settings/set_name_cmd.py @@ -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=_("✅ Имя бота успешно изменено на: {new_name}").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Попробуйте снова через: {retry_text}").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:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + state=state + ) + + except Exception as e: + logger.error(f"Непредвиденная ошибка при изменении имени: {e}") + await msg( + update=message, + text=_("❌ Непредвиденная ошибка:
{error}
").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 = _( + "🤖 Смена имени бота\n\n" + "Текущее имя: {current}\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) diff --git a/bot/handlers/commands/settings/set_widget_cmd.py b/bot/handlers/commands/settings/set_widget_cmd.py new file mode 100644 index 0000000..3959c88 --- /dev/null +++ b/bot/handlers/commands/settings/set_widget_cmd.py @@ -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=_("✅ Виджет бота успешно изменён на: {new_widget}").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Попробуйте снова через: {retry_text}").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 при изменении виджета:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + state=state + ) + + except Exception as e: + # Непредвиденная ошибка + logger.error(f"Непредвиденная ошибка при изменении виджета: {e}") + await msg( + update=message, + text=_("❌ Непредвиденная ошибка при изменении виджета:
{error}
").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 = _( + "📝 Смена виджета бота\n\n" + "Текущий виджет: {current}\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) diff --git a/bot/handlers/commands/settings/settings_cmd.py b/bot/handlers/commands/settings/settings_cmd.py new file mode 100644 index 0000000..4894044 --- /dev/null +++ b/bot/handlers/commands/settings/settings_cmd.py @@ -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) diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py new file mode 100644 index 0000000..d3fab41 --- /dev/null +++ b/bot/handlers/commands/users/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/users/admins.py b/bot/handlers/commands/users/admins.py new file mode 100644 index 0000000..61cc8e3 --- /dev/null +++ b/bot/handlers/commands/users/admins.py @@ -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"❌ Использование: /{command} " + + 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"{user_id} (@{username})" + return f"{user_id}" + + +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 + Пример: /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( + "⚠️ Вы уже владелец бота\n\n" + "Вам не нужно добавлять себя в администраторы", + parse_mode="HTML" + ) + return + + # Проверка: нельзя добавить другого владельца + if user_id in settings.OWNER_ID: + await message.answer( + "⚠️ Этот пользователь уже владелец бота\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"✅ Администратор добавлен\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"⚠️ Не может управлять другими админами\n" + f"Список админов: /listadmins" + ) + + logger.info( + f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})", + log_type="ADMIN_MGMT" + ) + else: + text = "❌ Ошибка добавления администратора\n\nПопробуйте позже" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT") + await message.answer("❌ Ошибка добавления\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 + Пример: /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( + "⚠️ Нельзя удалить владельца\n\n" + "Владельцы имеют права постоянно", + parse_mode="HTML" + ) + return + + # Проверка: нельзя удалить самого себя (если вы владелец) + if user_id == message.from_user.id: + await message.answer( + "⚠️ Нельзя удалить самого себя", + 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"🗑 Администратор удалён\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"⚠️ Пользователь больше не имеет доступа к командам бота" + ) + + logger.info( + f"Администратор удалён: {user_id} (удалил: {message.from_user.id})", + log_type="ADMIN_MGMT" + ) + else: + text = "❌ Ошибка удаления администратора\n\nПопробуйте позже" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT") + await message.answer("❌ Ошибка удаления\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 = "👥 СПИСОК АДМИНИСТРАТОРОВ\n\n" + + # Владельцы (OWNER_ID) + output += "👑 Владельцы бота (полные права):\n" + for owner_id in settings.OWNER_ID: + output += f"├─ {owner_id}\n" + output += "\n" + + # Администраторы из БД + if db_admins: + output += f"⚙️ Администраторы ({len(db_admins)}):\n" + + for admin_id in sorted(db_admins): + output += f"├─ {admin_id}\n" + + output += "\n" + output += "📋 Права администраторов:\n" + output += "├─ Управление банвордами\n" + output += "├─ Просмотр статистики\n" + output += "├─ Активация режимов модерации\n" + output += "└─ Все команды бота (кроме управления админами)\n\n" + else: + output += "⚙️ Администраторы:\n" + output += "└─ Нет дополнительных администраторов\n\n" + + # Общая статистика + total_admins = len(settings.OWNER_ID) + len(db_admins) + output += f"📊 Итого: {total_admins} администратор(ов)\n\n" + + # Команды управления + output += "🔧 Управление:\n" + output += "• /addadmin ID — добавить админа\n" + output += "• /remadmin ID — удалить админа\n\n" + + output += "💡 Только владельцы могут управлять администраторами" + + # Клавиатура + 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 = "❌ Ошибка загрузки списка\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 = ( + "➕ Как добавить администратора?\n\n" + "1️⃣ Узнайте Telegram ID пользователя\n" + " • Используйте бота @userinfobot\n" + " • Или попросите пользователя написать /start\n\n" + "2️⃣ Выполните команду:\n" + " /addadmin ID\n\n" + "Пример:\n" + "/addadmin 123456789" + ) + + 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 = ( + "👥 УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ\n\n" + "🔐 Уровни доступа:\n\n" + "👑 Владельцы (OWNER_ID):\n" + "├─ Все права администратора\n" + "├─ Управление другими админами\n" + "└─ Указываются в конфигурации\n\n" + "⚙️ Администраторы:\n" + "├─ Управление банвордами\n" + "├─ Просмотр статистики\n" + "├─ Активация режимов модерации\n" + "└─ НЕ могут управлять админами\n\n" + "📝 Команды:\n" + "• /listadmins — список всех админов\n" + "• /addadmin ID — добавить админа\n" + "• /remadmin ID — удалить админа\n\n" + "💡 Как узнать ID пользователя?\n" + "• Используйте бота @userinfobot\n" + "• Попросите пользователя написать боту\n" + "• ID отображается в логах бота\n\n" + "⚠️ Важно:\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 + """ + 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"🔍 Проверка пользователя\n\n" + text += f"👤 ID: {user_id}\n\n" + + if is_owner: + text += "👑 Статус: Владелец бота\n" + text += "✅ Полные права администратора\n" + text += "✅ Может управлять админами" + elif is_db_admin: + text += "⚙️ Статус: Администратор\n" + text += "✅ Доступ к командам бота\n" + text += "❌ Не может управлять админами" + else: + text += "👤 Статус: Обычный пользователь\n" + text += "❌ Нет прав администратора\n\n" + text += f"Добавить в админы: /addadmin {user_id}" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT") + await message.answer("❌ Ошибка проверки", parse_mode="HTML") diff --git a/bot/handlers/commands/users/conflict.py b/bot/handlers/commands/users/conflict.py new file mode 100644 index 0000000..8e75b95 --- /dev/null +++ b/bot/handlers/commands/users/conflict.py @@ -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"❌ Использование: /{command} [минуты]" + else: + return False, f"❌ Использование: /{command} [слово]" + + 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"✅ Конфликтное слово добавлено\n\n" + f"📝 Слово: {word}\n" + f"🔍 Тип: подстрока\n\n" + f"⚔️ Будет работать только в режиме антиконфликта\n" + f"Активируйте: /stopconflict [минуты]" + ) + else: + text = f"⚠️ Конфликтное слово {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления конфликтного слова: {e}", log_type="CONFLICT") + await message.answer("❌ Ошибка добавления\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"✅ Конфликтная лемма добавлена\n\n" + f"🔤 Слово: {word}\n" + f"🔍 Тип: лемма (все формы слова)\n\n" + f"⚔️ Будет работать только в режиме антиконфликта\n" + f"Активируйте: /stopconflict [минуты]" + ) + else: + text = f"⚠️ Конфликтная лемма {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления конфликтной леммы: {e}", log_type="CONFLICT") + await message.answer("❌ Ошибка добавления\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"🗑 Конфликтное слово удалено\n\n📝 Слово: {word}" + else: + text = f"⚠️ Конфликтное слово {word} не найдено" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления конфликтного слова: {e}", log_type="CONFLICT") + await message.answer("❌ Ошибка удаления\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"🗑 Конфликтная лемма удалена\n\n🔤 Слово: {word}" + else: + text = f"⚠️ Конфликтная лемма {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления конфликтной леммы: {e}", log_type="CONFLICT") + await message.answer("❌ Ошибка удаления\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( + "⚠️ Нет конфликтных слов\n\n" + "Сначала добавьте конфликтные слова:\n" + "• /addconflictword [слово]\n" + "• /addconflictlemma [слово]", + 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"⚔️ РЕЖИМ АНТИКОНФЛИКТА АКТИВИРОВАН\n\n" + f"⏱ Длительность: {time_str}\n" + f"🕐 Окончание: {expires_str}\n\n" + f"📊 Активные правила:\n" + f"├─ Конфликтные слова: {conflict_words_count}\n" + f"└─ Конфликтные леммы: {conflict_lemmas_count}\n\n" + f"⚠️ Обычные банворды временно отключены\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("❌ Ошибка активации режима\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( + "⚠️ Режим антиконфликта не активен\n\n" + "Активируйте: /stopconflict [минуты]", + parse_mode="HTML" + ) + return + + # Отключаем режим + await manager.disable_conflict_mode() + + text = ( + f"✅ Режим антиконфликта отключен\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("❌ Ошибка отключения режима\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"⚔️ РЕЖИМ АНТИКОНФЛИКТА АКТИВЕН\n\n" + f"⏱ Осталось: {format_time_str(time_left_minutes)}\n" + f"🕐 Окончание: {format_datetime(expires_at)}\n\n" + f"📊 Активные правила:\n" + f"├─ Конфликтные слова: {conflict_words_count}\n" + f"└─ Конфликтные леммы: {conflict_lemmas_count}\n\n" + f"⚠️ Обычные банворды отключены\n" + f"Отключить: /unstopconflict" + ) + else: + # Режим не активен + text = ( + f"💤 Режим антиконфликта НЕ активен\n\n" + f"📊 Конфликтных правил в базе:\n" + f"├─ Слова: {conflict_words_count}\n" + f"└─ Леммы: {conflict_lemmas_count}\n\n" + ) + + if total_conflict > 0: + text += f"Активировать: /stopconflict [минуты]" + else: + text += ( + f"⚠️ Нет конфликтных слов\n" + f"Добавьте:\n" + f"• /addconflictword [слово]\n" + f"• /addconflictlemma [слово]" + ) + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка получения статуса режима: {e}", log_type="CONFLICT") + await message.answer("❌ Ошибка получения статуса", parse_mode="HTML") diff --git a/bot/handlers/commands/users/emoji.py b/bot/handlers/commands/users/emoji.py new file mode 100644 index 0000000..807a908 --- /dev/null +++ b/bot/handlers/commands/users/emoji.py @@ -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'{emoji_char}' + + +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( + "❌ Используйте команду в ответ на сообщение\n\n" + "📝 Как использовать:\n" + "1. Ответьте на сообщение с премиум эмодзи\n" + "2. Напишите /emoji\n\n" + "💡 Бот извлечёт все кастомные эмодзи и покажет HTML-код", + parse_mode="HTML" + ) + return + + replied_message = message.reply_to_message + + # Извлекаем кастомные эмодзи + custom_emojis = extract_custom_emojis(replied_message) + + if not custom_emojis: + # Нет кастомных эмодзи + await message.answer( + "⚠️ Кастомные эмодзи не найдены\n\n" + "В этом сообщении нет премиум эмодзи.\n\n" + "💡 Попробуйте ответить на сообщение с анимированными эмодзи", + parse_mode="HTML" + ) + return + + # === ФОРМИРУЕМ ОТВЕТ === + + output = f"✨ НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}\n\n" + + for idx, emoji_data in enumerate(custom_emojis, 1): + emoji_char = emoji_data["char"] + emoji_id = emoji_data["id"] + + output += f"{idx}. Эмодзи: {emoji_char}\n" + output += f"📋 ID: {emoji_id}\n\n" + + # HTML-код (экранированный для отображения) + html_code = format_emoji_html(emoji_char, emoji_id) + html_escaped = escape_html(html_code) + + output += f"📝 HTML-код:\n" + output += f"{html_escaped}\n\n" + + # Пример использования + output += f"🎨 Превью: {html_code}\n" + + if idx < len(custom_emojis): + output += "\n" + "─" * 30 + "\n\n" + + output += "💡 Скопируйте HTML-код и используйте в своих сообщениях" + + # Создаём клавиатуру + 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( + "❌ Ошибка извлечения эмодзи\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 = ( + "🎨 РАБОТА С КАСТОМНЫМИ ЭМОДЗИ\n\n" + "📝 Команда /emoji\n" + "Извлекает ID премиум эмодзи из сообщения\n\n" + "🔧 Как использовать:\n" + "1️⃣ Ответьте на сообщение с эмодзи\n" + "2️⃣ Напишите /emoji\n" + "3️⃣ Скопируйте HTML-код\n\n" + "💻 Формат HTML-кода:\n" + "<tg-emoji emoji-id=\"ID\">fallback</tg-emoji>\n\n" + "📌 Пример использования в коде:\n" + "text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\n" + "await message.answer(text, parse_mode=\"HTML\")\n\n" + "⚠️ Важно:\n" + "├─ Используйте parse_mode=\"HTML\"\n" + "├─ Пользователи без Premium видят fallback\n" + "└─ Работает только с кастомными эмодзи\n\n" + "💡 Попробуйте отправить эмодзи и ответить командой /emoji" + ) + + await message.answer(text, parse_mode="HTML") diff --git a/bot/handlers/commands/users/id.py b/bot/handlers/commands/users/id.py new file mode 100644 index 0000000..a23fe07 --- /dev/null +++ b/bot/handlers/commands/users/id.py @@ -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 = "👤 ИНФОРМАЦИЯ О ВАС\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"📝 Имя: {full_name}\n" + + # Username + if user.username: + output += f"🔗 Username: @{user.username}\n" + else: + output += f"🔗 Username: не установлен\n" + + # ID + output += f"🆔 ID: {user.id}\n\n" + + # Тип аккаунта + if user.is_bot: + output += f"🤖 Тип: Бот\n" + elif user.is_premium: + output += f"⭐️ Тип: Premium пользователь\n" + else: + output += f"👥 Тип: Обычный пользователь\n" + + # Дополнительная информация + output += "\n📊 Дополнительно:\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: {message.chat.id}\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: {message.message_id}\n\n" + + # Подсказка + output += "💡 Эту информацию видите только вы" + + # Клавиатура + 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: {user.id}" + + 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 = "💬 ИНФОРМАЦИЯ О ЧАТЕ\n\n" + + # Тип чата + chat_types = { + "private": "💬 Личные сообщения", + "group": "👥 Группа", + "supergroup": "👥 Супергруппа", + "channel": "📢 Канал" + } + chat_type = chat_types.get(chat.type, "💬 Чат") + + output += f"📝 Тип: {chat_type}\n" + + if chat.title: + output += f"📌 Название: {chat.title}\n" + + if chat.username: + output += f"🔗 Username: @{chat.username}\n" + + output += f"🆔 Chat ID: {chat.id}\n" + + # Дополнительная информация для групп + if chat.type in ["group", "supergroup"]: + try: + member_count = await message.bot.get_chat_member_count(chat.id) + output += f"👥 Участников: {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 + ) diff --git a/bot/handlers/commands/users/listwords.py b/bot/handlers/commands/users/listwords.py new file mode 100644 index 0000000..2673f4a --- /dev/null +++ b/bot/handlers/commands/users/listwords.py @@ -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 "❌ Ошибка загрузки данных из базы" + + # === ФОРМИРУЕМ ВЫВОД === + + output = "📋 СПИСОК ПРАВИЛ МОДЕРАЦИИ\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"📊 Общая статистика:\n" + output += f"├─ Всего правил: {total_count}\n" + output += f"├─ Исключений: {len(exceptions)}\n" + output += f"├─ Удалений за всё время: {stats.get('total_deletions', 0)}\n" + output += f"└─ Администраторов: {stats.get('admins', 0)}\n\n" + + # === ПОСТОЯННЫЕ ПРАВИЛА === + if permanent_words or permanent_lemmas or permanent_parts: + output += "🔴 ПОСТОЯННЫЕ ПРАВИЛА:\n\n" + + if permanent_words: + output += f"📝 Подстроки ({len(permanent_words)}):\n" + words_str = ', '.join([f"{w}" for w in sorted(permanent_words)[:20]]) + if len(permanent_words) > 20: + words_str += f" ... (+{len(permanent_words) - 20} ещё)" + output += f"{words_str}\n\n" + + if permanent_lemmas: + output += f"🔤 Леммы ({len(permanent_lemmas)}):\n" + lemmas_str = ', '.join([f"{w}" for w in sorted(permanent_lemmas)[:20]]) + if len(permanent_lemmas) > 20: + lemmas_str += f" ... (+{len(permanent_lemmas) - 20} ещё)" + output += f"{lemmas_str}\n\n" + + if permanent_parts: + output += f"🧩 Части ({len(permanent_parts)}):\n" + parts_str = ', '.join([f"{w}" for w in sorted(permanent_parts)[:20]]) + if len(permanent_parts) > 20: + parts_str += f" ... (+{len(permanent_parts) - 20} ещё)" + output += f"{parts_str}\n\n" + + # === ВРЕМЕННЫЕ ПРАВИЛА === + if temp_words or temp_lemmas: + output += "⏱ ВРЕМЕННЫЕ ПРАВИЛА:\n\n" + + if temp_words: + output += f"📝 Временные подстроки ({len(temp_words)}):\n" + # Для временных слов нужна дополнительная информация о времени истечения + # Пока просто выводим список + words_str = ', '.join([f"{w}" for w in sorted(temp_words)[:15]]) + if len(temp_words) > 15: + words_str += f" ... (+{len(temp_words) - 15} ещё)" + output += f"{words_str}\n\n" + + if temp_lemmas: + output += f"🔤 Временные леммы ({len(temp_lemmas)}):\n" + lemmas_str = ', '.join([f"{w}" for w in sorted(temp_lemmas)[:15]]) + if len(temp_lemmas) > 15: + lemmas_str += f" ... (+{len(temp_lemmas) - 15} ещё)" + output += f"{lemmas_str}\n\n" + + # === КОНФЛИКТНЫЕ ПРАВИЛА === + if conflict_words or conflict_lemmas: + output += "⚔️ КОНФЛИКТНЫЕ ПРАВИЛА:\n" + output += "(работают только в режиме /stopconflict)\n\n" + + if conflict_words: + output += f"📝 Конфликтные слова ({len(conflict_words)}):\n" + words_str = ', '.join([f"{w}" for w in sorted(conflict_words)[:15]]) + if len(conflict_words) > 15: + words_str += f" ... (+{len(conflict_words) - 15} ещё)" + output += f"{words_str}\n\n" + + if conflict_lemmas: + output += f"🔤 Конфликтные леммы ({len(conflict_lemmas)}):\n" + lemmas_str = ', '.join([f"{w}" for w in sorted(conflict_lemmas)[:15]]) + if len(conflict_lemmas) > 15: + lemmas_str += f" ... (+{len(conflict_lemmas) - 15} ещё)" + output += f"{lemmas_str}\n\n" + + # === ИСКЛЮЧЕНИЯ (WHITELIST) === + if exceptions: + output += f"✅ ИСКЛЮЧЕНИЯ ({len(exceptions)}):\n" + exc_str = ', '.join([f"{exceptions}" for w in sorted(exceptions)[:15]]) + if len(exceptions) > 15: + exc_str += f" ... (+{len(exceptions) - 15} ещё)" + 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 += "🔴 АКТИВНЫЕ РЕЖИМЫ:\n" + for mode in active_modes: + output += f"{mode}\n" + output += "\n" + + # === ПУСТОЙ СПИСОК === + if total_count == 0: + output = ( + "📋 СПИСОК ПРАВИЛ МОДЕРАЦИИ\n\n" + "⚠️ Правила модерации не настроены\n\n" + "Используйте команды добавления:\n" + "• /addword — добавить подстроку\n" + "• /addlemma — добавить лемму\n" + "• /addpart — добавить часть\n\n" + "📖 Подробнее: /start" + ) + + # Ограничение длины (Telegram limit 4096) + if len(output) > 4000: + output = output[:3950] + "\n\n... список обрезан, слишком много правил" + + 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 = "❌ Ошибка загрузки списка\n\nПопробуйте позже" + + if is_callback: + await update.answer("❌ Ошибка загрузки", show_alert=True) + else: + await message.answer(error_text, parse_mode="HTML") diff --git a/bot/handlers/commands/users/notifications.py b/bot/handlers/commands/users/notifications.py new file mode 100644 index 0000000..beff962 --- /dev/null +++ b/bot/handlers/commands/users/notifications.py @@ -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🔨 Пользователь забанен (@{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"📊 Статистика пользователя\n\n" + text += f"🆔 ID: {user_id}\n" + text += f"🗑 Удалено сообщений: {spam_count}\n\n" + + if recent_spam: + text += f"📝 Последние нарушения:\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}. {matched_word} ({match_type})\n" + else: + text += "✅ Нет нарушений" + + await callback.answer(text, show_alert=True) + + except Exception as e: + logger.error(f"Ошибка получения статистики из уведомления: {e}", log_type="ERROR") + await callback.answer("❌ Ошибка получения статистики", show_alert=True) diff --git a/bot/handlers/commands/users/report.py b/bot/handlers/commands/users/report.py new file mode 100644 index 0000000..8ca7df4 --- /dev/null +++ b/bot/handlers/commands/users/report.py @@ -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( + "❌ Используйте команду в ответ на сообщение\n\n" + "Как использовать:\n" + "1. Ответьте на сообщение нарушителя\n" + "2. Напишите /report или /report причина\n\n" + "Пример: /report спам", + 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("❌ Ошибка получения данных пользователя", parse_mode="HTML") + return + + # Нельзя пожаловаться на самого себя + if reported_user.id == reporter.id: + await message.answer( + "⚠️ Нельзя пожаловаться на самого себя", + parse_mode="HTML" + ) + return + + # Нельзя пожаловаться на бота + if reported_user.is_bot: + await message.answer( + "⚠️ Нельзя пожаловаться на бота", + 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( + "⚠️ Нельзя пожаловаться на администратора", + 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 = "🚨 НОВЫЙ РЕПОРТ\n\n" + + # Информация о жалобщике + report_text += f"👤 От: {format_user(reporter)} ({reporter.id})\n" + + # Информация о нарушителе + report_text += f"⚠️ На: {format_user(reported_user)} ({reported_user.id})\n\n" + + # Информация о чате + chat_title = message.chat.title if message.chat.title else "Личные сообщения" + report_text += f"💬 Чат: {chat_title}\n" + report_text += f"🆔 Chat ID: {message.chat.id}\n\n" + + # Причина + report_text += f"📝 Причина: {reason}\n\n" + + # Текст сообщения + report_text += f"📄 Текст сообщения:\n" + + if reported_message.text: + truncated_text = truncate_text(reported_message.text, max_length=300) + report_text += f"{truncated_text}\n\n" + elif reported_message.caption: + truncated_caption = truncate_text(reported_message.caption, max_length=300) + report_text += f"{truncated_caption}\n\n" + else: + content_type = reported_message.content_type + report_text += f"[{content_type}]\n\n" + + # Время + report_text += f"🕐 Время: {format_datetime(datetime.now())}\n" + report_text += f"🔗 Message ID: {reported_message.message_id}\n\n" + + report_text += f"💡 ID репорта: {report_id}" + + # Клавиатура + 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( + "✅ Жалоба отправлена администраторам\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( + "❌ Ошибка отправки жалобы\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✅ Пользователь забанен ({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🗑 Сообщение удалено ({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✅ Репорт закрыт ({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 = ( + "🚨 СИСТЕМА РЕПОРТОВ\n\n" + "Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n" + "📝 Как пожаловаться:\n" + "1. Ответьте на сообщение нарушителя\n" + "2. Напишите /report\n" + "3. Можно указать причину: /report спам\n\n" + "✅ Примеры:\n" + "• /report — жалоба без причины\n" + "• /report спам — жалоба на спам\n" + "• /report оскорбления — жалоба на оскорбления\n\n" + "⚠️ Важно:\n" + "├─ Нельзя пожаловаться на себя\n" + "├─ Нельзя пожаловаться на ботов\n" + "├─ Нельзя пожаловаться на администраторов\n" + "└─ Ложные жалобы могут привести к бану\n\n" + "💡 Администраторы получат уведомление и примут меры" + ) + + 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 = ( + "📊 СТАТИСТИКА РЕПОРТОВ\n\n" + "⚠️ Функция в разработке\n\n" + "Планируется:\n" + "• Всего репортов за всё время\n" + "• Топ жалобщиков\n" + "• Топ нарушителей\n" + "• Распределение по причинам\n" + "• Статистика обработки\n\n" + "💡 Для реализации нужно добавить таблицу reports в БД" + ) + + await message.answer(text, parse_mode="HTML") diff --git a/bot/handlers/commands/users/slience.py b/bot/handlers/commands/users/slience.py new file mode 100644 index 0000000..3f2b4b8 --- /dev/null +++ b/bot/handlers/commands/users/slience.py @@ -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, "❌ Использование: /silence <минуты>" + + 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 = "🔄 РЕЖИМ ТИШИНЫ ОБНОВЛЁН" + else: + action_text = "🔇 РЕЖИМ ТИШИНЫ АКТИВИРОВАН" + + text = ( + f"{action_text}\n\n" + f"⏱ Длительность: {time_str}\n" + f"🕐 Окончание: {expires_str}\n\n" + f"⚠️ Что происходит:\n" + f"├─ Все сообщения от пользователей удаляются\n" + f"├─ Администраторы могут писать\n" + f"└─ Банворды временно отключены\n\n" + f"💡 Используйте для успокоения спора или флуда\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("❌ Ошибка активации режима\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( + "⚠️ Режим тишины не активен\n\n" + "Активируйте командой: /silence <минуты>", + parse_mode="HTML" + ) + return + + # Отключаем режим + await manager.disable_silence_mode() + + text = ( + f"✅ Режим тишины отключен\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("❌ Ошибка отключения режима\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"🔇 РЕЖИМ ТИШИНЫ АКТИВЕН\n\n" + f"⏱ Осталось: {format_time_str(time_left_minutes)}\n" + f"🕐 Окончание: {format_datetime(expires_at)}\n\n" + f"⚠️ Что происходит:\n" + f"├─ Все сообщения от пользователей удаляются\n" + f"├─ Администраторы могут писать\n" + f"└─ Банворды временно отключены\n\n" + f"💡 Для успокоения конфликта или флуда\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"💤 Режим тишины НЕ активен\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("❌ Ошибка получения статуса", 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( + "❌ Использование: /extend_silence <минуты>", + parse_mode="HTML" + ) + return + + # Проверяем, активен ли режим + manager = get_manager() + is_active = await manager.is_silence_active() + + if not is_active: + await message.answer( + "⚠️ Режим тишины не активен\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"⏱ РЕЖИМ ТИШИНЫ ПРОДЛЁН\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("❌ Ошибка продления режима\n\nПопробуйте позже", parse_mode="HTML") diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py new file mode 100644 index 0000000..12cd9d0 --- /dev/null +++ b/bot/handlers/commands/users/start_cmd.py @@ -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 = ( + "🤖 PrimoGuard - Бот-модератор\n\n" + "Автоматическое удаление сообщений с запрещёнными словами.\n" + "Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n" + ) + + # === Команды просмотра === + help_text += ( + "📋 Просмотр:\n" + "/list — список всех правил и слов\n" + "/stats — статистика по удалениям\n" + "/id — получение айди пользователя\n" + "/chatid — получение айди чата\n\n" + ) + + # === Постоянные банворды === + help_text += ( + "➕ Добавить банворд (постоянно):\n" + "/addword слово — подстрока (простой поиск)\n" + "/addlemma слово — лемма (все формы слова)\n" + "/addpart комбинация — часть (поиск без пробелов)\n\n" + ) + + # === Временные банворды === + help_text += ( + "⏱ Добавить банворд (временно):\n" + "/addtempword слово минуты — временная подстрока\n" + "/addtemplemma слово минуты — временная лемма\n" + "Пример: /addtempword спам 60\n\n" + ) + + # === Исключения (whitelist) === + help_text += ( + "✅ Исключения (whitelist):\n" + "/addexcept текст — добавить исключение\n" + "/remexcept текст — удалить исключение\n" + "Исключения не проверяются фильтром\n\n" + ) + + # === Режимы модерации === + help_text += ( + "🔇 Режим тишины:\n" + "/silence минуты — удалять ВСЕ сообщения\n" + "/unsilence — отключить режим тишины\n\n" + ) + + help_text += ( + "⚔️ Режим антиконфликта:\n" + "/addconflictword слово — добавить конфликтное слово\n" + "/addconflictlemma слово — добавить конфликтную лемму\n" + "/stopconflict минуты — активировать режим\n" + "/unstopconflict — отключить режим\n\n" + ) + + # === Удаление === + help_text += ( + "➖ Удалить:\n" + "/remword слово — удалить подстроку\n" + "/remlemma слово — удалить лемму\n" + "/rempart комбинация — удалить часть\n" + "/remtempword слово — удалить временную подстроку\n" + "/remtemplemma слово — удалить временную лемму\n" + "/remconflictword слово — удалить конфликтное слово\n" + "/remconflictlemma слово — удалить конфликтную лемму\n\n" + ) + + # === Управление админами (только для суперадминов) === + if is_super_admin: + help_text += ( + "👑 Управление админами (только для владельцев):\n" + "/addadmin ID — добавить администратора\n" + "/remadmin ID — удалить администратора\n" + "/listadmins — список всех админов\n\n" + ) + + # === Типы проверок === + help_text += ( + "ℹ️ Типы проверок:\n" + "• Подстрока — простой поиск в тексте\n" + "• Лемма — все формы слова (купить→куплю, купил, купишь...)\n" + "• Часть — поиск без пробелов (обходит \"к у п и т ь\")\n" + "• Временные — автоматически удаляются через N минут\n" + "• Конфликтные — работают только в режиме /stopconflict\n\n" + ) + + help_text += ( + "🔧 Технологии:\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) diff --git a/bot/handlers/commands/users/stats.py b/bot/handlers/commands/users/stats.py new file mode 100644 index 0000000..cef5ddf --- /dev/null +++ b/bot/handlers/commands/users/stats.py @@ -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 = "📊 СТАТИСТИКА PRIMOGUARD\n\n" + + # Общая информация + total_deletions = stats.get('total_deletions', 0) + output += f"🗑 Всего удалений: {format_number(total_deletions)}\n\n" + + # Активные режимы + if is_silence or is_conflict: + output += "🔴 АКТИВНЫЕ РЕЖИМЫ:\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"🔇 Режим тишины\n" + output += f"├─ ⏱ Осталось: {format_time_remaining(time_left_minutes)}\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"⚔️ Режим антиконфликта\n" + output += f"├─ ⏱ Осталось: {format_time_remaining(time_left_minutes)}\n" + output += f"├─ 🕐 До: {format_datetime(conflict_until)}\n" + output += f"└─ 📊 Правил: {total_conflict}\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"📋 Правила модерации:\n" + output += f"├─ Всего правил: {total_rules}\n" + output += f"├─ Постоянные: {len(data.get('substring', set())) + len(data.get('lemma', set())) + len(data.get('part', set()))}\n" + output += f"├─ Временные: {len(data.get('temp_substring', set())) + len(data.get('temp_lemma', set()))}\n" + output += f"├─ Конфликтные: {len(data.get('conflict_substring', set())) + len(data.get('conflict_lemma', set()))}\n" + output += f"└─ Исключения: {len(data.get('whitelist', set()))}\n\n" + + # Топ-5 спамеров + if top_spammers: + output += "🏆 Топ-5 спамеров:\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}. {user_id} — {count} [{bar}]\n" + + output += "\n" + else: + output += "🏆 Топ-5 спамеров:\n" + output += "└─ Нет данных\n\n" + + # Администраторы + admins_count = len(settings.OWNER_ID) + len(data.get('admins', set())) + output += f"👥 Администраторов: {admins_count}\n\n" + + # Подсказка + output += "💡 Используйте кнопки для детальной информации" + + # Клавиатура + 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 = "❌ Ошибка загрузки статистики\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 = "📊 ДЕТАЛЬНАЯ СТАТИСТИКА\n\n" + + # Подробная статистика удалений + total_deletions = stats.get('total_deletions', 0) + output += f"🗑 Удаления сообщений:\n" + output += f"├─ Всего: {format_number(total_deletions)}\n" + output += "\n" + + # Активные режимы (детально) + is_silence = await manager.is_silence_active() + is_conflict = await manager.is_conflict_active() + + if is_silence or is_conflict: + output += "🔴 Активные режимы:\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"🔇 Режим тишины:\n" + output += f"├─ Статус: ✅ Активен\n" + output += f"├─ Осталось: {format_time_remaining(time_left_minutes)}\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"⚔️ Режим антиконфликта:\n" + output += f"├─ Статус: ✅ Активен\n" + output += f"├─ Осталось: {format_time_remaining(time_left_minutes)}\n" + output += f"├─ Окончание: {format_datetime(conflict_until)}\n" + output += f"├─ Слов: {conflict_words_count}\n" + output += f"├─ Лемм: {conflict_lemmas_count}\n" + output += f"└─ Эффект: Обычные банворды отключены\n\n" + + # Детальная статистика правил + output += f"📋 Правила модерации:\n\n" + + output += f"🔴 Постоянные:\n" + output += f"├─ Подстроки: {len(data.get('substring', set()))}\n" + output += f"├─ Леммы: {len(data.get('lemma', set()))}\n" + output += f"└─ Части: {len(data.get('part', set()))}\n\n" + + output += f"⏱ Временные:\n" + output += f"├─ Подстроки: {len(data.get('temp_substring', set()))}\n" + output += f"└─ Леммы: {len(data.get('temp_lemma', set()))}\n\n" + + output += f"⚔️ Конфликтные:\n" + output += f"├─ Слова: {len(data.get('conflict_substring', set()))}\n" + output += f"└─ Леммы: {len(data.get('conflict_lemma', set()))}\n\n" + + output += f"✅ Исключения: {len(data.get('whitelist', set()))}\n\n" + + # Информация о кэше + cache_info = stats.get('cache_active', False) + cache_updated = stats.get('cache_updated_at', None) + + output += f"💾 Кэш:\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 = "🏆 ТОП-10 СПАМЕРОВ\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} {user_id}\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"📊 Статистика:\n" + output += f"├─ Всего пользователей: {total_spammers}\n" + output += f"└─ Всего удалений: {format_number(total_deletions)}\n\n" + + output += "💡 ID можно использовать для проверки пользователя" + else: + output += "└─ Нет данных об удалениях\n\n" + output += "💡 Когда бот начнёт удалять сообщения, здесь появится статистика" + + # Кнопка возврата + 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 = ( + "🔤 ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ\n\n" + "📭 Статистика пока пуста\n\n" + "Срабатывания появятся после удаления\n" + "первых спам-сообщений." + ) + else: + text = "🔤 ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ\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}{i}. {emoji} {word} — {count} раз\n" + + # Общая статистика + total = await manager.get_total_spam_count() + text += f"\n📊 Всего удалено: {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 + Пример: /userstats 123456789 + """ + parts = message.text.split(maxsplit=1) + + if len(parts) < 2: + await message.answer( + "❌ Использование: /userstats [ID]\n\n" + "Пример: /userstats 123456789", + 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"👤 СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ\n\n" + output += f"🆔 ID: {user_id}\n\n" + + if user_spam_count > 0: + output += f"🗑 Удалено сообщений: {format_number(user_spam_count)}\n\n" + + if user_spam_stats: + output += f"📝 Последние удаления:\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"│ └─ Слово: {matched_word} ({match_type})\n" + + output += "\n" + else: + output += "✅ Нет нарушений\n\n" + output += "Этот пользователь не нарушал правила чата" + + await message.answer(output, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка получения статистики пользователя: {e}", log_type="STATS") + await message.answer("❌ Ошибка загрузки статистики", 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( + "⚠️ ВНИМАНИЕ!\n\n" + "Эта команда удалит ВСЮ статистику удалений:\n" + "• Счётчики удалений пользователей\n" + "• Историю удалённых сообщений\n" + "• Топ спамеров\n\n" + "Правила модерации НЕ будут удалены.\n\n" + "Для подтверждения используйте:\n" + "/resetstats confirm", + parse_mode="HTML" + ) + return + + manager = get_manager() + + try: + # Сбрасываем статистику + deleted_count = await manager.reset_spam_stats() + + if deleted_count > 0: + await message.answer( + f"✅ Статистика сброшена\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( + "ℹ️ Статистика уже пуста", + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Ошибка сброса статистики: {e}", log_type="STATS") + await message.answer("❌ Ошибка сброса статистики", parse_mode="HTML") + diff --git a/bot/handlers/commands/users/word.py b/bot/handlers/commands/users/word.py new file mode 100644 index 0000000..78083ae --- /dev/null +++ b/bot/handlers/commands/users/word.py @@ -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"❌ Использование: /{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}" + + 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} {word_type.capitalize()} {word} {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"⚠️ Подстрока {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Лемма {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Часть {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления части: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Временная подстрока {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Временная лемма {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Исключение {word} уже существует" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD") + await message.answer("❌ Ошибка добавления\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"⚠️ Подстрока {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\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"⚠️ Лемма {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\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"⚠️ Часть {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления части: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\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"⚠️ Временная подстрока {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\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"⚠️ Временная лемма {word} не найдена" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\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"⚠️ Исключение {word} не найдено" + + await message.answer(text, parse_mode="HTML") + + except Exception as e: + logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD") + await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") diff --git a/bot/handlers/messages/__init__.py b/bot/handlers/messages/__init__.py new file mode 100644 index 0000000..9efe58a --- /dev/null +++ b/bot/handlers/messages/__init__.py @@ -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) diff --git a/bot/handlers/messages/default_msg.py b/bot/handlers/messages/default_msg.py new file mode 100644 index 0000000..517c143 --- /dev/null +++ b/bot/handlers/messages/default_msg.py @@ -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 diff --git a/bot/handlers/messages/ping_test.py b/bot/handlers/messages/ping_test.py new file mode 100644 index 0000000..8e2bf2a --- /dev/null +++ b/bot/handlers/messages/ping_test.py @@ -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 diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..93f594b --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1,2 @@ +from .inline import * +from .reply import * diff --git a/bot/keyboards/inline.py b/bot/keyboards/inline.py new file mode 100644 index 0000000..05d39bd --- /dev/null +++ b/bot/keyboards/inline.py @@ -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() diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py new file mode 100644 index 0000000..ca2e2a9 --- /dev/null +++ b/bot/keyboards/inline/__init__.py @@ -0,0 +1 @@ +from .decision import * diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py new file mode 100644 index 0000000..e9bb032 --- /dev/null +++ b/bot/keyboards/inline/decision.py @@ -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() diff --git a/bot/keyboards/reply.py b/bot/keyboards/reply.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..0a3d294 --- /dev/null +++ b/bot/middlewares/__init__.py @@ -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 diff --git a/bot/middlewares/banwords_mdw.py b/bot/middlewares/banwords_mdw.py new file mode 100644 index 0000000..7f0d327 --- /dev/null +++ b/bot/middlewares/banwords_mdw.py @@ -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"🚫 Удалено сообщение\n\n" + f"👤 Пользователь: {username}\n" + f"🆔 ID: {user.id}\n" + f"📊 Нарушений: {spam_count}\n\n" + f"🔍 Триггер: {matched_word}\n" + f"📝 Тип: {self._get_type_emoji(match_type)} {match_type}\n\n" + f"💬 Текст:\n" + f"{self._escape_html(message_text[:500])}" + ) + + # Создаём клавиатуру с действиями + 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(">", ">") + ) diff --git a/bot/middlewares/error_mdw.py b/bot/middlewares/error_mdw.py new file mode 100644 index 0000000..8fecd61 --- /dev/null +++ b/bot/middlewares/error_mdw.py @@ -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} Ошибка в боте\n\n" + f"📊 Информация:\n" + f"├─ Тип: {error_type}\n" + f"├─ Категория: {category.value}\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"💬 Сообщение:\n{error_msg}\n\n" + else: + notification += f"💬 Сообщение:\n{error_msg[:200]}...\n\n" + + # Добавляем контекст события + notification += f"📋 Контекст:\n" + + if event_info.get('text'): + notification += f"├─ Текст: {event_info['text']}\n" + + if event_info.get('callback_data'): + notification += f"├─ Callback: {event_info['callback_data']}\n" + + if event_info.get('content_info'): + notification += f"├─ Контент: {event_info['content_info']}\n" + + if event_info.get('chat_type'): + notification += f"└─ Тип чата: {event_info['chat_type']}\n" + + # Добавляем статистику + stats = self.stats.get_summary() + notification += ( + f"\n📊 Статистика:\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"🚨 Статистика ошибок\n\n" + f"📊 Общая информация:\n" + f"├─ Всего ошибок: {stats['total_errors']}\n" + f"└─ Время работы: {stats['uptime']}\n\n" + ) + + # По категориям + if stats['by_category']: + text += f"📁 По категориям:\n" + for category, count in stats['by_category'].items(): + text += f"├─ {category}: {count}\n" + text += "\n" + + # По типам исключений + if stats['by_exception']: + text += f"🔧 По типам (топ-5):\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 diff --git a/bot/middlewares/logging_mdw.py b/bot/middlewares/logging_mdw.py new file mode 100644 index 0000000..5cc1299 --- /dev/null +++ b/bot/middlewares/logging_mdw.py @@ -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']}" + ) diff --git a/bot/middlewares/referal_mdw.py b/bot/middlewares/referal_mdw.py new file mode 100644 index 0000000..3ef50a8 --- /dev/null +++ b/bot/middlewares/referal_mdw.py @@ -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}" diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py new file mode 100644 index 0000000..bcd15b1 --- /dev/null +++ b/bot/middlewares/spam_mdw.py @@ -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"🚫 Вы заблокированы за спам!\n\n" + f"⏳ Оставшееся время: {self._format_duration(remaining)}\n" + f"⚠️ Предупреждений: {user_stats.warnings}" + ) + + 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"🚫 Вы заблокированы за спам!\n\n" + f"⏳ Длительность: {self._format_duration(block_duration)}\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"🚫 Вы заблокированы за спам!\n\n" + f"⏳ Длительность: {self._format_duration(block_duration)}\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"⚠️ Предупреждение #{user_stats.warnings}\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 + } diff --git a/bot/middlewares/sub_mdw.py b/bot/middlewares/sub_mdw.py new file mode 100644 index 0000000..9f150ac --- /dev/null +++ b/bot/middlewares/sub_mdw.py @@ -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 = "📢 Для использования бота необходимо подписаться на каналы:\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( + "✅ Подписка подтверждена!\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}" + ) diff --git a/bot/middlewares/time_mdw.py b/bot/middlewares/time_mdw.py new file mode 100644 index 0000000..2333c7b --- /dev/null +++ b/bot/middlewares/time_mdw.py @@ -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 + ) diff --git a/bot/special/__init__.py b/bot/special/__init__.py new file mode 100644 index 0000000..054aaf3 --- /dev/null +++ b/bot/special/__init__.py @@ -0,0 +1 @@ +from .text_processing import * diff --git a/bot/special/text_processing.py b/bot/special/text_processing.py new file mode 100644 index 0000000..1bd52c6 --- /dev/null +++ b/bot/special/text_processing.py @@ -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) diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/templates/__init__.py b/bot/templates/__init__.py new file mode 100644 index 0000000..b969334 --- /dev/null +++ b/bot/templates/__init__.py @@ -0,0 +1 @@ +from .message_callback import * diff --git a/bot/templates/message_callback.py b/bot/templates/message_callback.py new file mode 100644 index 0000000..6cba0d7 --- /dev/null +++ b/bot/templates/message_callback.py @@ -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 + } diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..bfe2697 --- /dev/null +++ b/bot/utils/__init__.py @@ -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 * + diff --git a/bot/utils/argument.py b/bot/utils/argument.py new file mode 100644 index 0000000..d2871ec --- /dev/null +++ b/bot/utils/argument.py @@ -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', + ... ['', '[duration]'], + ... {'reason': 'Причина бана', 'silent': 'Тихий бан'}, + ... 'Банит пользователя' + ... ) + >> print(usage) + """ + lines = [] + + # Описание + if description: + lines.append(f"📝 {description}\n") + + # Использование + args_str = ' '.join(args) + lines.append(f"Использование:") + lines.append(f"/{command} {args_str}\n") + + # Аргументы + if args: + lines.append("Аргументы:") + 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("Флаги:") + 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) diff --git a/bot/utils/auto_delete.py b/bot/utils/auto_delete.py new file mode 100644 index 0000000..300d060 --- /dev/null +++ b/bot/utils/auto_delete.py @@ -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 diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py new file mode 100644 index 0000000..21b424f --- /dev/null +++ b/bot/utils/decorators.py @@ -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) diff --git a/bot/utils/format_time.py b/bot/utils/format_time.py new file mode 100644 index 0000000..91e4f0d --- /dev/null +++ b/bot/utils/format_time.py @@ -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') diff --git a/bot/utils/hidden_username.py b/bot/utils/hidden_username.py new file mode 100644 index 0000000..8d2472c --- /dev/null +++ b/bot/utils/hidden_username.py @@ -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" + ) diff --git a/bot/utils/state_utils.py b/bot/utils/state_utils.py new file mode 100644 index 0000000..3c87674 --- /dev/null +++ b/bot/utils/state_utils.py @@ -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 = [ + "🔍 Debug FSM:\n", + f"📊 Состояние: {current_state or 'None'}\n", + f"📦 Данных: {len(data)}\n" + ] + + if data: + lines.append("\nДанные:") + for key, value in data.items(): + value_str = str(value) + if len(value_str) > 50: + value_str = value_str[:50] + "..." + lines.append(f"• {key}: {value_str}") + + return "\n".join(lines) diff --git a/bot/utils/type_message.py b/bot/utils/type_message.py new file mode 100644 index 0000000..4dec0aa --- /dev/null +++ b/bot/utils/type_message.py @@ -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 diff --git a/bot/utils/usernames.py b/bot/utils/usernames.py new file mode 100644 index 0000000..b2eff8c --- /dev/null +++ b/bot/utils/usernames.py @@ -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) + 'John Doe' + + >> get_user_mention(message, parse_mode='Markdown') + '[John Doe](tg://user?id=123456789)' + + >> get_user_mention(message, show_username=True) + '@johndoe' + """ + 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'{display_text}' + 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) + 'John Doe' + + >> 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'{display}' + + # 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) + } diff --git a/configs/__init__.py b/configs/__init__.py new file mode 100644 index 0000000..5952572 --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1,3 @@ +from .cmd_alias_list import * +from .config import * +from .mapping import * diff --git a/configs/cmd_alias_list.py b/configs/cmd_alias_list.py new file mode 100644 index 0000000..eff3c27 --- /dev/null +++ b/configs/cmd_alias_list.py @@ -0,0 +1,363 @@ +from typing import Final + +# Настройка экспорта в модули +__all__ = ("COMMANDS",) + +# Список команд по ключу +COMMANDS: Final[dict[str, list[str]]] = { + "start": [ + "start", "старт", "почати", # основные + "ыефке", "cnfhn", "gjxfnb", # раскладка + "st", "on", "вкл", # сокращения + ], + + "help": [ + "help", "помощь", "допомога", # основные + "рудз", "gjvjom", "ljgjvjuf", # раскладка + "h", "хелп", "?", # сокращения + ], + + "menu": [ + "menu", "меню", # основные + "vtym", "vtye", # раскладка + "m", "mn", # сокращения + ], + + "stats": [ + "stats", "статистика", "стат", # основные + "cnfnbcnbrf", "cnfn", "cns", # раскладка + "stat", "st", "s", # сокращения + ], + + # ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ==================== + + "addword": [ + "addword", "добавитьслово", # основные + "фввцщкв", "lj,fdbnmckjdj", # раскладка + "aw", "addw", "добслово", # сокращения + ], + + "addlemma": [ + "addlemma", "добавитьлемму", # основные + "фввдуььф", "lj,fdbnmktve", # раскладка + "al", "addl", "доблемму", # сокращения + ], + + "addpart": [ + "addpart", "добавитьчасть", # основные + "фввзфке", "lj,fdbnmxfcnm", # раскладка + "ap", "addp", "добчасть", # сокращения + ], + + # ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ==================== + + "addtempword": [ + "addtempword", "добавитьвремслово", # основные + "фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка + "atw", "addtw", "темпслово", # сокращения + ], + + "addtemplemma": [ + "addtemplemma", "добавитьвремлемму", # основные + "фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка + "atl", "addtl", "темплемму", # сокращения + ], + + # ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== + + "addexcept": [ + "addexcept", "добавитьисключение", # основные + "фввучсузе", "lj,fdbnmbcrkx", # раскладка + "axc", "addwhite", "искл", # сокращения + ], + + # ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ==================== + + "remword": [ + "remword", "удалитьслово", # основные + "кутцщкв", "elfkbnmckjdj", # раскладка + "rw", "delword", "dw", "удслово", # сокращения + ], + + "remlemma": [ + "remlemma", "удалитьлемму", # основные + "кутдуььф", "elfkbnmktve", # раскладка + "rl", "dellemma", "dl", "удлемму", # сокращения + ], + + "rempart": [ + "rempart", "удалитьчасть", # основные + "кутзфке", "elfkbnmxfcnm", # раскладка + "rp", "delpart", "dp", "удчасть", # сокращения + ], + + # ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ==================== + + "remtempword": [ + "remtempword", "удалитьвремслово", # основные + "кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка + "rtw", "deltw", "удтемпслово", # сокращения + ], + + "remtemplemma": [ + "remtemplemma", "удалитьвремлемму", # основные + "кутеуьздуььф", "elfkbnmdhtvktve", # раскладка + "rtl", "deltl", "удтемплемму", # сокращения + ], + + # ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== + + "remexcept": [ + "remexcept", "удалитьисключение", # основные + "кутучсузе", "elfkbnmbcrkx", # раскладка + "rxc", "remwhite", "удискл", # сокращения + ], + + # ==================== КОНФЛИКТНЫЕ СЛОВА ==================== + + "addconflictword": [ + "addconflictword", "добавитьконфликт", # основные + "фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка + "acw", "addcw", "конфслово", # сокращения + ], + + "addconflictlemma": [ + "addconflictlemma", "добавитьконфлемму", # основные + "фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка + "acl", "addcl", "конфлемму", # сокращения + ], + + "remconflictword": [ + "remconflictword", "удалитьконфликт", # основные + "кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка + "rcw", "delcw", "удконфликт", # сокращения + ], + + "remconflictlemma": [ + "remconflictlemma", "удалитьконфлемму", # основные + "кутсщтakшседуььф", "elfkbnmrjyaktve", # раскладка + "rcl", "delcl", "удконфлемму", # сокращения + ], + + # ==================== РЕЖИМ АНТИКОНФЛИКТА ==================== + + "stopconflict": [ + "stopconflict", "стопконфликт", # основные + "cnjgsщтakшse", "cnjzrjyakbrn", # раскладка + "sconf", "sc", "стопконф", # сокращения + ], + + "unstopconflict": [ + "unstopconflict", "отменаконфликта", # основные + "eycnjgsщтakшse", "jnvtyf", # раскладка + "usconf", "usc", "откконф", # сокращения + ], + + "conflictstatus": [ + "conflictstatus", "статусконфликта", # основные + "сщтakшseыефnec", "cnfnec", # раскладка + "cstatus", "cs", "статконф", # сокращения + ], + + # ==================== РЕЖИМ ТИШИНЫ ==================== + + "silence": [ + "silence", "тишина", "мут", # основные + "ышдутсу", "nbibyf", "ven", # раскладка + "sil", "mute", "quiet", "тиш", # сокращения + ], + + "unsilence": [ + "unsilence", "отменатишины", # основные + "eтышдутсу", "jnvtyf", # раскладка + "unsil", "unmute", "откмут", # сокращения + ], + + "silencestatus": [ + "silencestatus", "статустишины", # основные + "ышдутсуыефnec", "cnfnec", # раскладка + "sstatus", "ss", "статтиш", # сокращения + ], + + "extend_silence": [ + "extend_silence", "продлитьтишину", # основные + "уче_ышдутсу", "ghjlkbnmnbibyet", # раскладка + "exsil", "exs", "продтиш", # сокращения + ], + + # ==================== АДМИНИСТРАТОРЫ ==================== + + "addadmin": [ + "addadmin", "добавитьадмина", # основные + "фввфвьшт", "lj,fdbnmflvbyf", # раскладка + "aa", "addadm", "добадм", # сокращения + ], + + "remadmin": [ + "remadmin", "удалитьадмина", # основные + "кутфвьшт", "elfkbnmflvbyf", # раскладка + "ra", "remadm", "deladmin", "удадм", # сокращения + ], + + "listadmins": [ + "listadmins", "списокадминов", # основные + "дшыефвьшты", "cgbcjrflvbyjd", # раскладка + "admins", "adm", "adminlist", "адм", # сокращения + ], + + "adminhelp": [ + "adminhelp", "помощьадмину", # основные + "фвьштрудз", "gjvjomflvbyt", # раскладка + "admhelp", "ah", "хелпадм", # сокращения + ], + + "checkadmin": [ + "checkadmin", "проверкаадмина", # основные + "сруслфвьшт", "ghjdthrf", # раскладка + "isadmin", "ca", "провадм", # сокращения + ], + + # ==================== ПРОСМОТР ==================== + + "list": [ + "listwords", "списокслов", # основные + "дшыецщквы", "cgbcjrckjd", # раскладка + "lw", "list", "дшые", "words", "слова", # сокращения + ], + + "listlemmas": [ + "listlemmas", "списоклемм", # основные + "дшыедуььфы", "cgbcjrktv", # раскладка + "ll", "lemmas", "леммы", # сокращения + ], + + "listparts": [ + "listparts", "списокчастей", # основные + "дшыезфкеы", "cgbcjrxfcntq", # раскладка + "lp", "parts", "части", # сокращения + ], + + "listexcept": [ + "listexcept", "списокисключений", # основные + "дшыеучсузе", "cgbcjrbcrkx", # раскладка + "lxc", "except", "white", "искл", # сокращения + ], + + "listconflict": [ + "listconflict", "списокконфликтов", # основные + "дшыесщтakшse", "cgbcjrrjyakbrnjd", # раскладка + "lc", "conflict", "конф", # сокращения + ], + + # ==================== СТАТИСТИКА ==================== + + "userstats": [ + "userstats", "статистикапользователя", # основные + "ecthыефnы", "cnfnbcnbrf", # раскладка + "ustat", "us", "статюзер", # сокращения + ], + + "resetstats": [ + "resetstats", "сброситьстат", # основные + "кыуеыефnы", "c,hjcbnm", # раскладка + "rstats", "clearstats", "сброс", # сокращения + ], + + # ==================== ИНФОРМАЦИЯ ==================== + + "id": [ + "id", "айди", "инфо", # основные + "шв", "fqlb", "byaj", # раскладка + "info", "me", "мои", # сокращения + ], + + "myid": [ + "myid", "мойайди", # основные + "ьншв", "vjqfqlb", # раскладка + "mid", "мид", # сокращения + ], + + "chatid": [ + "chatid", "айдичата", # основные + "срфешв", "fqlbxfnf", # раскладка + "cid", "чатид", # сокращения + ], + + # ==================== РЕПОРТЫ ==================== + + "report": [ + "report", "репорт", "жалоба", # основные + "кузщке", "htgjhn", ";fkj,f", # раскладка + "rep", "r", "жал", # сокращения + ], + + "reporthelp": [ + "reporthelp", "помощьрепорт", # основные + "кузщкерудз", "gjvjomhtgjhn", # раскладка + "rephelp", "rh", "хелпреп", # сокращения + ], + + "reportstats": [ + "reportstats", "статистикарепортов", # основные + "кузщкеыефnы", "cnfnbcnbrf", # раскладка + "rstat", "rs", "статреп", # сокращения + ], + + "checkreport": [ + "checkreport", "проверкарепорта", # основные + "сруслкузщке", "ghjdthrf", # раскладка + "crep", "cr", "провреп", # сокращения + ], + + "closereport": [ + "closereport", "закрытьрепорт", # основные + "сдщыукузщке", "pfrhsnm", # раскладка + "close", "cl", "закреп", # сокращения + ], + + "banreport": [ + "banreport", "забанитьрепорт", # основные + "фтшкузщке", "pf,fybnm", # раскладка + "banrep", "br", "банреп", # сокращения + ], + + # ==================== ЭМОДЗИ ==================== + + "emoji": [ + "emoji", "эмодзи", # основные + "уьщош", "'vjlpb", # раскладка + "em", "emj", "эм", # сокращения + ], + + "emojihelp": [ + "emojihelp", "помощьэмодзи", # основные + "уьщошрудз", "gjvjom'vjlpb", # раскладка + "emhelp", "emh", "хелпэм", # сокращения + ], + + # ==================== СИСТЕМНЫЕ ==================== + + "ping": [ + "ping", "пинг", # основные + "зштп", "gbyp", # раскладка + "p", "пн", # сокращения + ], + + "version": [ + "version", "версия", # основные + "дукышщт", "dthcbz", # раскладка + "ver", "v", "вер", # сокращения + ], + + "reload": [ + "reload", "перезагрузка", # основные + "кудщфв", "gthtpfuheprf", # раскладка + "rl", "restart", "рест", # сокращения + ], + + "logs": [ + "logs", "логи", # основные + "дщпы", "kjub", # раскладка + "log", "l", "лог", # сокращения + ], +} diff --git a/configs/config.py b/configs/config.py new file mode 100644 index 0000000..1f5af80 --- /dev/null +++ b/configs/config.py @@ -0,0 +1,224 @@ +from pathlib import Path +from urllib.parse import urlparse, ParseResult +from typing import Optional, Any +from secrets import token_urlsafe + +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from aiogram.types import ChatAdministratorRights + + +class _Settings(BaseSettings): + """Настройки бота с комплексной валидацией""" + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + validate_default=True, + ) + + # ============== ОСНОВНЫЕ ПАРАМЕТРЫ ============== + # Токены бота + BOT_TOKEN: Optional[str] = None + + # Параметры сообщений + PARSE_MODE: str = "HTML" + PREFIX: str = "/!.&?" + + # Разрешения и логирование + BOT_EDIT: bool = False + START_INFO_CONSOLE: bool = True + START_INFO_TO_FILE: bool = True + LOG_CONSOLE: bool = True + LOG_FILE: bool = True + LOG_DIR: Path = Path('Logs') + LOG_FILE_INFO: Path = Path('bot_info.log') + LOG_ROTATION: str = '100 MB' + LOG_RETENTION: str = '7 days' + + # Вебхук + WEBHOOK: bool = False + SECRET_TOKEN: Optional[str] = '' + WEBHOOK_URL: Optional[str] = None + WEBAPP_HOST: str = "0.0.0.0" + WEBAPP_PORT: int = 3131 + LOG_LEVEL: str = "warning" + ACCES_LOG: bool = False + + # API ключи + API_KEY: Optional[str] = None + WEB_API_KEY: Optional[str] = None + WEATHER_API_KEY: Optional[str] = None + + # Идентификаторы + OWNER_ID: list[int] = [6751720805] + ADMIN_ID: list[int] = [] + ADMIN_CHAT_ID: int = 0 + + # Настройки бота + BOT_NAME: str = "Бот" + BOT_DESCRIPTION: Optional[str] = None + BOT_SHORT_DESCRIPTION: Optional[str] = None + + # Права администратора + ANONYMOUS: bool = False + MANAGE_CHAT: bool = True + CHANGE_INFO: bool = True + PROMOTE_MEMBERS: bool = True + RESTRICT_MEMBERS: bool = True + POST_MESSAGE: bool = True + MANAGE_TOPICS: bool = True + INVITE_USER: bool = True + DELETE_MESSAGES: bool = True + MANAGE_VIDEO_CHATS: bool = True + EDIT_MESSAGES: bool = True + PIN_MESSAGE: bool = True + POST_STORIES: bool = True + EDIT_STORIES: bool = True + DELETE_STORIES: bool = True + + # Настройки сообщений + DISABLE_NOTIFICATION: bool = False + PROTECT_CONTENT: bool = False + ALLOW_SENDING_WITHOUT_REPLY: bool = True + LINK_PREVIEW_IS_DISABLED: bool = False + LINK_PREVIEW_PREFER_SMALL_MEDIA: bool = False + LINK_PREVIEW_PREFER_LARGE_MEDIA: bool = True + LINK_PREVIEW_SHOW_ABOVE_TEXT: bool = True + SHOW_CAPTION_ABOVE_MEDIA: bool = False + + # улучшения + ANTI_SPAM: bool = True + + # ================= ВАЛИДАТОРЫ ================= + @field_validator('PARSE_MODE') + def validate_parse_mode(cls, v: str) -> str: + allowed_modes: set[str] = {"HTML", "Markdown", "MarkdownV2"} + if v not in allowed_modes: + raise ValueError(f"Недопустимый PARSE_MODE. Допустимые: {', '.join(allowed_modes)}") + return v + + @field_validator('PREFIX') + def validate_prefix(cls, v: str) -> str: + cleaned: str = ''.join(dict.fromkeys(v)) # Удаление дубликатов с сохранением порядка + if len(cleaned) < 1: + raise ValueError("PREFIX должен содержать хотя бы один символ") + return cleaned + + @field_validator('LOG_DIR', 'LOG_FILE_INFO', 'POSTS_DIR', mode='before') + def validate_paths(cls, v: Any) -> Path: + return Path(v) if isinstance(v, str) else v + + @field_validator('WEBHOOK_URL') + def validate_webhook_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + parsed: ParseResult = urlparse(v) + if not all([parsed.scheme, parsed.netloc]): + raise ValueError("Некорректный URL вебхука") + if parsed.scheme != 'https': + raise ValueError("WEBHOOK_URL должен использовать HTTPS") + return v + + @field_validator('BOT_NAME') + def validate_non_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Поле не может быть пустым") + return v + + @model_validator(mode='after') + def validate_bot_token(self) -> "_Settings": + if not self.BOT_TOKEN: + raise ValueError("Требуется BOT_TOKEN для рабочего режима") + return self + + @model_validator(mode='after') + def validate_webhook_config(self) -> "_Settings": + if self.WEBHOOK and not self.WEBHOOK_URL: + raise ValueError("WEBHOOK_URL обязателен при включенном WEBHOOK") + + # ✅ Генерация SECRET_TOKEN если не установлен + if self.WEBHOOK and not self.SECRET_TOKEN: + self.SECRET_TOKEN = token_urlsafe(32) + + return self + + @model_validator(mode='after') + def validate_logging_paths(self) -> "_Settings": + if self.LOG_FILE: + self.LOG_DIR.mkdir(parents=True, exist_ok=True) + + # ✅ Создание директории для постов + if not self.POSTS_DIR.exists(): + self.POSTS_DIR.mkdir(parents=True, exist_ok=True) + + return self + + @model_validator(mode='after') + def set_dynamic_descriptions(self) -> "_Settings": + if self.BOT_DESCRIPTION is None: + self.BOT_DESCRIPTION = f"Ваш помощник в удивительные миры! Prod. by:『@verdise』" + if self.BOT_SHORT_DESCRIPTION is None: + self.BOT_SHORT_DESCRIPTION = f"Тех.поддержка: @verdise" + return self + + # ================= СВОЙСТВА ================= + + @property + def rights(self) -> ChatAdministratorRights: + """Права администратора бота""" + return ChatAdministratorRights( + is_anonymous=self.ANONYMOUS, + can_manage_chat=self.MANAGE_CHAT, + can_delete_messages=self.DELETE_MESSAGES, + can_manage_video_chats=self.MANAGE_VIDEO_CHATS, + can_restrict_members=self.RESTRICT_MEMBERS, + can_promote_members=self.PROMOTE_MEMBERS, + can_change_info=self.CHANGE_INFO, + can_invite_users=self.INVITE_USER, + can_post_stories=self.POST_STORIES, + can_edit_stories=self.EDIT_STORIES, + can_delete_stories=self.DELETE_STORIES, + can_post_messages=self.POST_MESSAGE, + can_edit_messages=self.EDIT_MESSAGES, + can_pin_messages=self.PIN_MESSAGE, + can_manage_topics=self.MANAGE_TOPICS, + ) + + @property + def active_bot_token(self) -> str: + """Активный токен бота в зависимости от режима""" + if not self.BOT_TOKEN: + raise ValueError("Активный токен бота отсутствует") + return self.BOT_TOKEN + + @property + def log_dir_absolute(self) -> Path: + """Абсолютный путь к директории логов""" + return self.LOG_DIR.absolute() + + @property + def super_admin_ids(self) -> set[int]: + """Множество ID суперадминов (для банвордов)""" + return set(self.OWNER_ID) + + +# ✅ Единственный экземпляр настроек +settings = _Settings() + +# ✅ ОПЦИОНАЛЬНО: Простые константы для обратной совместимости (без дублирования) +# Используются только для удобства импорта, но ссылаются на settings +BOT_TOKEN = settings.active_bot_token +ADMIN_CHAT_ID = settings.ADMIN_CHAT_ID +SUPER_ADMIN_IDS = settings.super_admin_ids +WORDS_FILE = settings.WORDS_FILE + +# Экспорт +__all__ = ( + 'settings', + 'BOT_TOKEN', + 'ADMIN_CHAT_ID', + 'SUPER_ADMIN_IDS', + 'WORDS_FILE', +) diff --git a/configs/mapping.py b/configs/mapping.py new file mode 100644 index 0000000..549df05 --- /dev/null +++ b/configs/mapping.py @@ -0,0 +1,163 @@ +""" +Словари для нормализации текста и замены символов +""" +from typing import Dict + +# Словарь замены латинских букв на кириллические (для обхода фильтров) +LATIN_TO_CYRILLIC: Dict[str, str] = { + 'a': 'а', 'A': 'А', + 'b': 'б', 'B': 'В', + 'c': 'с', 'C': 'С', + 'e': 'е', 'E': 'Е', + 'h': 'н', 'H': 'Н', + 'k': 'к', 'K': 'К', + 'm': 'м', 'M': 'М', + 'o': 'о', 'O': 'О', + 'p': 'р', 'P': 'Р', + 't': 'т', 'T': 'Т', + 'x': 'х', 'X': 'Х', + 'y': 'у', 'Y': 'У' +} + +# Словарь замены похожих кириллических букв (украинские, белорусские и т.д.) +CYRILLIC_NORMALIZE: Dict[str, str] = { + 'ґ': 'г', 'Ґ': 'Г', # украинское Ґ + 'є': 'е', 'Є': 'Е', # украинское Є + 'і': 'и', 'І': 'И', # украинское І + 'ї': 'и', 'Ї': 'И', # украинское Ї + 'ў': 'у', 'Ў': 'У', # белорусское Ў + 'ѐ': 'е', 'Ѐ': 'Е', # кириллица с грависом + 'ё': 'е', 'Ё': 'Е', # ё -> е для упрощения +} + +# Большой словарь Unicode-символов -> кириллица/латиница +UNICODE_MAP: Dict[str, str] = { + # === ЛАТИНСКИЕ БУКВЫ -> КИРИЛЛИЦА === + 'a': 'а', 'A': 'А', 'b': 'б', 'B': 'В', 'c': 'с', 'C': 'С', + 'd': 'д', 'D': 'Д', 'e': 'е', 'E': 'Е', 'f': 'ф', 'F': 'Ф', + 'g': 'г', 'G': 'Г', 'h': 'н', 'H': 'Н', 'i': 'и', 'I': 'И', + 'j': 'ж', 'J': 'Ж', 'k': 'к', 'K': 'К', 'l': 'л', 'L': 'Л', + 'm': 'м', 'M': 'М', 'n': 'н', 'N': 'Н', 'o': 'о', 'O': 'О', + 'p': 'р', 'P': 'Р', 'q': 'к', 'Q': 'К', 'r': 'р', 'R': 'Р', + 's': 'с', 'S': 'С', 't': 'т', 'T': 'Т', 'u': 'у', 'U': 'У', + 'v': 'в', 'V': 'В', 'w': 'ш', 'W': 'Ш', 'x': 'х', 'X': 'Х', + 'y': 'у', 'Y': 'У', 'z': 'з', 'Z': 'З', + + # === SMALL CAPS === + 'ᴀ': 'а', 'ʙ': 'б', 'ᴄ': 'с', 'ᴅ': 'д', 'ᴇ': 'е', 'ꜰ': 'ф', + 'ɢ': 'г', 'ʜ': 'н', 'ɪ': 'и', 'ᴊ': 'ж', 'ᴋ': 'к', 'ʟ': 'л', + 'ᴍ': 'м', 'ɴ': 'н', 'ᴏ': 'о', 'ᴘ': 'р', 'ꞯ': 'к', 'ʀ': 'р', + 'ꜱ': 's', 'ᴛ': 'т', 'ᴜ': 'у', 'ᴠ': 'в', 'ᴡ': 'ш', 'ʏ': 'у', 'ᴢ': 'з', + 'ᴦ': 'г', 'ᴧ': 'л', 'ʍ': 'м', 'ᴨ': 'п', 'ᴩ': 'р', 'ɸ': 'ф', 'ɯ': 'ш', + + # === ГРЕЧЕСКИЕ === + 'α': 'а', 'Α': 'А', 'β': 'б', 'Β': 'В', 'γ': 'г', 'Γ': 'Г', + 'δ': 'д', 'Δ': 'Д', 'ε': 'е', 'Ε': 'Е', 'ζ': 'з', 'Ζ': 'З', + 'η': 'н', 'Η': 'Н', 'θ': 'т', 'Θ': 'Т', 'ι': 'и', 'Ι': 'И', + 'κ': 'к', 'Κ': 'К', 'λ': 'л', 'Λ': 'Л', 'μ': 'м', 'Μ': 'М', + 'ν': 'н', 'Ν': 'Н', 'ξ': 'кс', 'Ξ': 'КС', 'ο': 'о', 'Ο': 'О', + 'π': 'п', 'Π': 'П', 'ρ': 'р', 'Ρ': 'Р', 'σ': 'с', 'Σ': 'С', + 'τ': 'т', 'Τ': 'Т', 'υ': 'у', 'Υ': 'У', 'φ': 'ф', 'Φ': 'Ф', + 'χ': 'х', 'Χ': 'Х', 'ψ': 'пс', 'Ψ': 'ПС', 'ω': 'о', 'Ω': 'О', + 'ύ': 'у', 'ϱ': 'р', 'ς': 'с', 'ϲ': 'с', 'ϕ': 'ф', 'ϰ': 'к', + 'ϻ': 'м', 'ϸ': 'ш', 'ϙ': 'к', 'ϝ': 'в', '϶': 'э', 'ʐ': 'з', + 'ʒ': 'ж', 'ʂ': 'ш', 'ʈ': 'т', 'ɳ': 'н', 'ɭ': 'л', 'ƙ': 'к', + 'ɼ': 'р', 'ʠ': 'к', 'ɩ': 'и', 'ʝ': 'ж', 'ɦ': 'х', 'ɠ': 'г', + 'ɗ': 'д', 'ɓ': 'б', 'ɞ': 'е', 'ƒ': 'ф', 'ɧ': 'х', 'ʑ': 'з', + 'ɱ': 'м', 'ƴ': 'у', 'ʌ': 'л', 'ƿ': 'р', 'ɾ': 'р', 'ɟ': 'ж', + 'ɥ': 'х', 'ɰ': 'м', 'ѕ': 'с', 'ѡ': 'ш', + + # === КОПТСКИЕ === + 'ⲁ': 'а', 'Ⲁ': 'А', 'ⳝ': 'б', 'Ⳝ': 'Б', 'ⲃ': 'в', 'Ⲃ': 'В', + 'ⲅ': 'г', 'Ⲅ': 'Г', 'ⲇ': 'д', 'Ⲇ': 'Д', 'ⲉ': 'е', 'Ⲉ': 'Е', + 'ⲯ': 'ж', 'Ⲯ': 'Ж', 'ⳅ': 'з', 'Ⳅ': 'З', 'ⲕ': 'к', 'Ⲕ': 'К', + 'ⲗ': 'л', 'Ⲗ': 'Л', 'ⲙ': 'м', 'Ⲙ': 'М', 'ⲏ': 'н', 'Ⲏ': 'Н', + 'ⲟ': 'о', 'Ⲟ': 'О', 'ⲡ': 'п', 'Ⲡ': 'П', 'ⲣ': 'р', 'Ⲣ': 'Р', + 'ⲥ': 'с', 'Ⲥ': 'С', 'ⲧ': 'т', 'Ⲧ': 'Т', 'ⲩ': 'у', 'Ⲩ': 'У', + 'ⲫ': 'ф', 'Ⲫ': 'Ф', 'ⲭ': 'х', 'Ⲭ': 'Х', 'ⳡ': 'ч', 'Ⳡ': 'Ч', + 'ⲱ': 'ш', 'Ⲱ': 'Ш', 'ⳃ': 'щ', 'Ⳃ': 'Щ', 'ⳗ': 'ж', 'Ⳗ': 'Ж', + 'ⳋ': 'г', 'Ⳋ': 'Г', 'ⳑ': 'л', 'Ⳑ': 'Л', 'ⲋ': 'с', 'Ⲋ': 'С', + 'ⳳ': 'в', 'Ⳳ': 'В', 'ⲍ': 'з', 'Ⲍ': 'З', 'ⲓ': 'и', 'Ⲓ': 'И', + 'ⲛ': 'н', 'Ⲛ': 'Н', 'Ⳙ': 'у', 'ⳙ': 'у', + + # === КИРИЛЛИЧЕСКИЕ СТИЛИЗОВАННЫЕ === + 'ѧ': 'а', 'ѣ': 'е', 'ґ': 'г', 'Ґ': 'Г', 'є': 'е', 'Є': 'Е', + 'ѫ': 'о', 'Ѫ': 'О', 'ӡ': 'з', 'Ӡ': 'З', 'џ': 'дж', 'Џ': 'ДЖ', + 'ӣ': 'и', 'Ӣ': 'И', 'ѳ': 'ф', 'Ѳ': 'Ф', 'ⱀ': 'н', 'ҁ': 'ч', + 'ѻ': 'о', 'Ѻ': 'О', 'ҵ': 'ц', 'Ҵ': 'Ц', 'ӌ': 'ч', 'Ӌ': 'Ч', + 'ѱ': 'пс', 'Ѱ': 'ПС', 'ƀ': 'б', 'ѥ': 'е', 'Ѥ': 'Е', + 'ᴙ': 'я', 'і': 'и', 'І': 'И', 'ї': 'и', 'Ї': 'И', + 'ў': 'у', 'Ў': 'У', 'ӷ': 'г', 'Ӷ': 'Г', 'ӄ': 'к', 'Ӄ': 'К', + 'ҁ': 'ч', 'Ҁ': 'Ч', 'ӽ': 'х', 'Ӽ': 'Х', 'ҕ': 'г', 'Ҕ': 'Г', + 'ѵ': 'в', 'Ѵ': 'В', 'ʯ': 'ч', 'ɜ': 'з', + + # === TAI THAM === + 'ᥲ': 'а', 'ᥱ': 'е', 'ᥙ': 'и', 'ᥔ': 'й', '᧘': 'л', 'ᥒ': 'н', + '᧐': 'о', 'ᥰ': 'п', 'ᥴ': 'с', '᥊': 'х', '᥎': 'в', 'ᥕ': 'ш', + 'ᤋ': 'з', 'ᤁ': 'з', 'ᥣ': 'л', 'ꤌ': 'а', 'ꤒ': 'б', 'ꤐ': 'в', + '꤅': 'д', 'ꤕ': 'е', 'ꤣ': 'и', '꤇': 'й', '꤀': 'о', 'ꤙ': 'п', + 'ꤍ': 'с', 'ꤟ': 'ч', '꤈': 'л', 'ꤤ': 'д', 'ꤖ': 'х', '꤯': 'ж', + 'ꤗ': 'х', '꤂': 'в', 'ꤘ': 'з', 'ꤎ': 'я', 'მ': 'м', + + # === СТАРЫЕ ИТАЛИЙСКИЕ === + '𐌀': 'А', '𐌁': 'В', '𐌂': 'С', '𐌄': 'Е', '𐌅': 'Ф', '𐌉': 'И', + '𐌊': 'К', '𐌋': 'Ж', '𐌑': 'М', '𐌏': 'О', '𐌐': 'Г', '𐌛': 'Р', + '𐌕': 'Т', '𐌖': 'В', '𐌗': 'Х', '𐌟': 'Ж', '𐌍': 'Й', '𐍔': 'У', + '𐌔': 'З', '𐌒': 'К', '𐌓': 'Я', '𐍃': 'С', '𐌴': 'Э', '𐍂': 'Р', + '𐌜': 'Ь', '𐌆': 'Ж', '𐍆': 'Ф', '𐌺': 'К', '𐌡': 'Л', '𐌌': 'М', + '𐌻': 'л', '𐌼': 'м', '𐌽': 'н', '𐌸': 'щ', '𐍅': 'у', '𐍉': 'я', + '𐌵': 'у', '𐋏': 'н', '𐠨': 'в', + + # === ДЕВАНАГАРИ И ПРОЧИЕ === + '𑀐': 'г', '𑀥': 'д', '𑀝': 'с', '𑀡': 'ж', '𑀗': 'с', '𑀱': 'т', + '𑀉': 'л', '𑀌': 'х', '𑀨': 'ь', 'ઠ': 'б', 'ਡ': 'з', 'ਘ': 'щ', + 'ੜ': 'р', '੮': 'т', 'ਜ': 'ж', 'ઞ': 'о', 'ʆ': 'ж', 'ʠ': 'к', + 'ക': 'к', 'ሏ': 'е', 'ይ': 'е', 'ሦ': 'ж', 'ን': 'з', 'ሀ': 'и', + 'ህ': 'х', 'ኸ': 'к', 'በ': 'м', 'ጠ': 'м', 'ዘ': 'н', 'ዐ': 'о', + 'ከ': 'к', 'የ': 'р', 'ር': 'с', 'ፐ': 'т', 'ነ': 'у', 'ዋ': 'ф', + 'ጰ': 'х', 'ሃ': 'ч', 'ሠ': 'ш', 'ሡ': 'щ', 'ፊ': 'ы', 'ሪ': 'ь', + 'ጓ': 'э', 'ሬ': 'ю', 'ጸ': 'я', 'ል': 'а', 'ፔ': 'б', 'ፎ': 'в', + 'ታ': 'г', 'ፑ': 'ф', 'ፘ': 'г', 'ፗ': 'ж', 'ጋ': 'ж', 'ረ': 'л', + 'ዓ': 'к', 'ዩ': 'р', 'ና': 'с', 'ሏ': 'д', 'ጠ': 'м', 'ፗ': 'ж', + 'ᱧ': 'к', 'ᱦ': 'ш', 'ຸ': 'у', 'ս': 'у', 'Ա': 'ч', 'Կ': 'ч', + 'Ꮁ': 'Г', 'Ꮾ': 'Б', 'Ꮶ': 'К', 'Ꮧ': 'Л', 'Ꮇ': 'М', 'Ꮋ': 'Н', + 'Ꮻ': 'О', 'Ꮲ': 'Р', 'Ꮯ': 'С', 'Ꭲ': 'Т', 'Ꭹ': 'У', 'Ꮱ': 'Ф', + 'Ꮞ': 'Ч', 'Ꮚ': 'Ш', 'Ꮗ': 'Щ', 'Ꭺ': 'А', 'Ꭰ': 'Д', 'Ꭼ': 'Е', + 'Ꮀ': 'Ф', 'Ꮐ': 'Г', 'Ꮖ': 'И', 'Ꭻ': 'Ж', 'Ꮮ': 'Л', 'Ꮢ': 'Р', + 'Ꮪ': 'С', 'Ꮜ': 'У', 'Ꮩ': 'В', 'Ꮃ': 'Ш', 'Ꮓ': 'З', 'Ꮐ': 'Г', + 'Ꭷ': 'К', 'Ꮀ': 'Х', 'Ᏼ': 'В', 'Ᏽ': 'Г', 'ᏏᏓ': 'Ы', 'Ꮟ': 'Ь', + 'ᎰᏫ': 'Ю', 'ᕒ': 'З', 'ᕈ': 'Р', 'ᑲ': 'Б', 'ᑯ': 'Д', 'ᴊ': 'Ж', + + # === FULLWIDTH === + 'a': 'а', 'b': 'б', 'c': 'с', 'd': 'д', 'e': 'е', 'f': 'ф', + 'g': 'г', 'h': 'н', 'i': 'и', 'j': 'ж', 'k': 'к', 'l': 'л', + 'm': 'м', 'n': 'н', 'o': 'о', 'p': 'р', 'q': 'к', 'r': 'р', + 's': 'с', 't': 'т', 'u': 'у', 'v': 'в', 'w': 'ш', 'x': 'х', + 'y': 'у', 'z': 'з', + + # === МАТЕМАТИЧЕСКИЕ === + '𝐚': 'а', '𝐛': 'б', '𝐜': 'с', '𝐝': 'д', '𝐞': 'е', '𝐟': 'ф', + '𝐠': 'г', '𝐡': 'н', '𝐢': 'и', '𝐣': 'ж', '𝐤': 'к', '𝐥': 'л', + '𝐦': 'м', '𝐧': 'н', '𝐨': 'о', '𝐩': 'р', '𝐪': 'к', '𝐫': 'р', + '𝐬': 'с', '𝐭': 'т', '𝐮': 'у', '𝐯': 'в', '𝐰': 'ш', '𝐱': 'х', + '𝐲': 'у', '𝐳': 'з', + + # === CIRCLED === + 'ⓐ': 'а', 'ⓑ': 'б', 'ⓒ': 'с', 'ⓓ': 'д', 'ⓔ': 'е', 'ⓕ': 'ф', + 'ⓖ': 'г', 'ⓗ': 'н', 'ⓘ': 'и', 'ⓙ': 'ж', 'ⓚ': 'к', 'ⓛ': 'л', + 'ⓜ': 'м', 'ⓝ': 'н', 'ⓞ': 'о', 'ⓟ': 'р', 'ⓠ': 'к', 'ⓡ': 'р', + 'ⓢ': 'с', 'ⓣ': 'т', 'ⓤ': 'у', 'ⓥ': 'в', 'ⓦ': 'ш', 'ⓧ': 'х', + 'ⓨ': 'у', 'ⓩ': 'з', + + # === GUJARATI/DEVANAGARI === + 'ુ': 'у', 'ૠ': 'р', '૦': 'о', 'વ': 'к', 'ઽ': 'с', 'પ': 'ч', + 'ક': 'к', '𑀋': 'х', 'ળ': 'я', 'ખ': 'ы', 'उ': 'з', 'چ': 'б', + 'ሩ': 'ю', 'ራ': 'ь', 'ል': 'а', 'ዓ': 'к', 'ዩ': 'р', 'ና': 'с', + '∂': 'д', '⨍': 'ф', 'ϻ': 'м', 'ጋ': 'ж', 'პ': 'п', 'ჰ': 'х', + 'Ͷ': 'М', 'Ͳ': 'Т', 'Ϸ': 'Р', 'Ϥ': 'Ч', 'Ͽ': 'Э', 'Ϳ': 'Ж', + 'Ɗ': 'Д', 'Ɠ': 'Г', 'Ɍ': 'Р', 'Ʋ': 'У', 'Ɲ': 'Н', 'Ϙ': 'К', + 'ϒ': 'У', 'ζ': 'з', 'Ŵ': 'Ш', '℔': 'Ы', 'ሃ': 'х', + + # === ЦИФРЫ КАК БУКВЫ === + '0': 'о', '1': 'и', '3': 'з', '4': 'ч', '5': 'с', '7': 'т', '8': 'в', +} diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..3ef18cf --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,39 @@ +""" +Database модуль для работы с банвордами в SQLite. + +Использует SQLAlchemy ORM для async работы с БД. + +Структура: +- models.py: Модели таблиц (BanWord, TempBanWord, WhitelistWord, Admin, Setting, SpamStat) +- database.py: Подключение к БД через SQLAlchemy +- repository.py: CRUD операции через ORM +- manager.py: Высокоуровневый API для handlers/middleware + +Usage: + from database import get_manager, BanWordType + + # Инициализация + manager = get_manager() + await manager.init() + + # Добавление банворда + await manager.add_banword("спам", BanWordType.SUBSTRING, added_by=123) + + # Проверка (из кэша - быстро) + words = manager.get_banwords_cached(BanWordType.SUBSTRING) + if "спам" in text and "спам" in words: + await manager.log_spam(...) + + # Режим тишины + await manager.set_silence_mode(minutes=30) + if await manager.is_silence_active(): + # Удаляем всё +""" + +from .models import * + +from .database import * + +from .repository import * + +from .manager import * diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..4198491 --- /dev/null +++ b/database/database.py @@ -0,0 +1,115 @@ +""" +Управление SQLAlchemy движком и сессиями. +""" +from pathlib import Path +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + create_async_engine, + async_sessionmaker, + AsyncSession, + AsyncEngine +) + +from middleware.loggers import logger +from .models import Base + +__all__ = ("Database", "get_db") + + +class Database: + """ + Менеджер SQLAlchemy базы данных. + + Attributes: + engine: Async движок SQLAlchemy + session_factory: Фабрика сессий + """ + + def __init__(self, db_path: str = "banwords.db"): + """ + Args: + db_path: Путь к SQLite файлу + """ + # Создаём директорию если не существует + db_file = Path(db_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + + # SQLite URL для async + db_url = f"sqlite+aiosqlite:///{db_path}" + + # Создаём async движок + self.engine: AsyncEngine = create_async_engine( + db_url, + echo=False, # Логирование SQL запросов (False для прода) + future=True, + pool_pre_ping=True, # Проверка соединения + ) + + # Фабрика сессий + self.session_factory = async_sessionmaker( + self.engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + logger.info( + f"SQLAlchemy инициализирован: {db_path}", + log_type="DATABASE" + ) + + async def init(self) -> None: + """Создаёт все таблицы в БД""" + try: + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + logger.info( + "Таблицы базы данных созданы", + log_type="DATABASE" + ) + except Exception as e: + logger.error( + f"Ошибка создания таблиц: {e}", + log_type="DATABASE" + ) + raise + + async def close(self) -> None: + """Закрывает соединения с БД""" + await self.engine.dispose() + logger.info("База данных закрыта", log_type="DATABASE") + + def get_session(self) -> AsyncGenerator[AsyncSession, None]: + """ + Создаёт новую сессию (контекстный менеджер). + + Usage: + async with db.get_session() as session: + result = await session.execute(select(BanWord)) + words = result.scalars().all() + + Yields: + AsyncSession: Сессия для работы с БД + """ + return self.session_factory() + + +# Глобальный экземпляр +_db_instance: Database | None = None + + +def get_db(db_path: str = "banwords.db") -> Database: + """ + Возвращает глобальный экземпляр Database (Singleton). + + Args: + db_path: Путь к БД (используется только при первом вызове) + + Returns: + Database: Экземпляр базы данных + """ + global _db_instance + if _db_instance is None: + _db_instance = Database(db_path) + return _db_instance diff --git a/database/manager.py b/database/manager.py new file mode 100644 index 0000000..1a98295 --- /dev/null +++ b/database/manager.py @@ -0,0 +1,582 @@ +""" +Высокоуровневый менеджер для работы с банвордами. +Упрощает использование repository в handlers и middleware. +""" +from typing import Set, Optional, List, Dict, Any +from datetime import datetime + +from middleware.loggers import logger +from .database import Database, get_db +from .repository import BanWordsRepository +from .models import BanWordType, SpamStat, SpamLog + +from sqlalchemy import select, delete, func, desc + +__all__ = ("BanWordsManager", "get_manager") + + +class BanWordsManager: + """ + Менеджер для удобной работы с банвордами. + + Предоставляет упрощённый API для handlers и middleware. + + Attributes: + db: Экземпляр Database + repo: Repository для CRUD операций + """ + + def __init__(self, db: Optional[Database] = None): + """ + Args: + db: Экземпляр Database (если None, берётся глобальный) + """ + self.db = db or get_db() + self.repo = BanWordsRepository(self.db) + + # Кэш для часто используемых данных + self._cache_banwords: Optional[dict] = None + self._cache_whitelist: Optional[Set[str]] = None + self._cache_admins: Optional[Set[int]] = None + self._cache_updated_at: Optional[datetime] = None + + async def init(self) -> None: + """Инициализирует базу данных и загружает кэш""" + await self.db.init() + await self.refresh_cache() + logger.info("BanWordsManager инициализирован", log_type="DATABASE") + + async def close(self) -> None: + """Закрывает соединение с БД""" + await self.db.close() + + # === CACHE MANAGEMENT === + + async def refresh_cache(self) -> None: + """Обновляет кэш из БД""" + try: + self._cache_banwords = await self.repo.get_all_banwords() + temp_banwords = await self.repo.get_all_temp_banwords() + + # Объединяем постоянные и временные банворды + for word_type, words in temp_banwords.items(): + if word_type in self._cache_banwords: + self._cache_banwords[word_type] |= words + + self._cache_whitelist = await self.repo.get_whitelist() + self._cache_admins = await self.repo.get_admins() + self._cache_updated_at = datetime.now() + + logger.debug("Кэш банвордов обновлён", log_type="DATABASE") + + except Exception as e: + logger.error(f"Ошибка обновления кэша: {e}", log_type="DATABASE") + + def invalidate_cache(self) -> None: + """Сбрасывает кэш (требует refresh_cache)""" + self._cache_banwords = None + self._cache_whitelist = None + self._cache_admins = None + self._cache_updated_at = None + + # === BANWORDS (с кэшем) === + + async def add_banword( + self, + word: str, + word_type: BanWordType, + added_by: Optional[int] = None, + reason: Optional[str] = None, + refresh_cache: bool = True + ) -> bool: + """ + Добавляет банворд и обновляет кэш. + + Args: + word: Слово + word_type: Тип + added_by: ID админа + reason: Причина + refresh_cache: Обновить кэш после добавления + + Returns: + bool: True если добавлен + """ + result = await self.repo.add_banword(word, word_type, added_by, reason) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + async def remove_banword( + self, + word: str, + word_type: BanWordType, + refresh_cache: bool = True + ) -> bool: + """Удаляет банворд и обновляет кэш""" + result = await self.repo.remove_banword(word, word_type) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + def get_banwords_cached(self, word_type: BanWordType) -> Set[str]: + """ + Получает банворды из кэша (быстро). + + Args: + word_type: Тип банвордов + + Returns: + Set[str]: Набор слов из кэша + """ + if self._cache_banwords is None: + logger.warning("Кэш не инициализирован", log_type="DATABASE") + return set() + + return self._cache_banwords.get(word_type, set()) + + async def get_banwords(self, word_type: BanWordType) -> Set[str]: + """Получает банворды напрямую из БД (без кэша)""" + return await self.repo.get_banwords(word_type) + + # === TEMPORARY BANWORDS === + + async def add_temp_banword( + self, + word: str, + word_type: BanWordType, + minutes: int, + added_by: Optional[int] = None, + refresh_cache: bool = True + ) -> bool: + """Добавляет временный банворд""" + result = await self.repo.add_temp_banword( + word, word_type, minutes, added_by + ) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + async def remove_temp_banword( + self, + word: str, + word_type: BanWordType, + refresh_cache: bool = True + ) -> bool: + """Удаляет временный банворд""" + result = await self.repo.remove_temp_banword(word, word_type) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + async def cleanup_expired(self) -> int: + """ + Очищает истёкшие временные банворды. + Вызывается периодически (например, раз в минуту). + + Returns: + int: Количество удалённых записей + """ + deleted = await self.repo.cleanup_expired_temp_banwords() + + if deleted > 0: + await self.refresh_cache() + + return deleted + + # === WHITELIST === + + async def add_whitelist( + self, + word: str, + added_by: Optional[int] = None, + reason: Optional[str] = None, + refresh_cache: bool = True + ) -> bool: + """Добавляет слово в белый список""" + result = await self.repo.add_whitelist(word, added_by, reason) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + async def remove_whitelist( + self, + word: str, + refresh_cache: bool = True + ) -> bool: + """Удаляет слово из белого списка""" + result = await self.repo.remove_whitelist(word) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + def get_whitelist_cached(self) -> Set[str]: + """Получает белый список из кэша""" + if self._cache_whitelist is None: + logger.warning("Кэш whitelist не инициализирован", log_type="DATABASE") + return set() + + return self._cache_whitelist + + def is_whitelisted(self, text: str) -> bool: + """ + Проверяет, содержит ли текст слово из белого списка. + + Args: + text: Текст для проверки (lowercase) + + Returns: + bool: True если найдено исключение + """ + whitelist = self.get_whitelist_cached() + return any(word in text for word in whitelist) + + # === ADMINS === + + async def add_admin( + self, + user_id: int, + added_by: Optional[int] = None, + refresh_cache: bool = True + ) -> bool: + """Добавляет администратора""" + result = await self.repo.add_admin(user_id, added_by) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + async def remove_admin( + self, + user_id: int, + refresh_cache: bool = True + ) -> bool: + """Удаляет администратора""" + result = await self.repo.remove_admin(user_id) + + if result and refresh_cache: + await self.refresh_cache() + + return result + + def get_admins_cached(self) -> Set[int]: + """Получает список админов из кэша""" + if self._cache_admins is None: + logger.warning("Кэш админов не инициализирован", log_type="DATABASE") + return set() + + return self._cache_admins + + def is_admin_cached(self, user_id: int) -> bool: + """ + Проверяет, является ли пользователь админом (из кэша). + + Args: + user_id: Telegram ID + + Returns: + bool: True если админ + """ + return user_id in self.get_admins_cached() + + async def is_admin(self, user_id: int) -> bool: + """Проверяет админа напрямую из БД""" + return await self.repo.is_admin(user_id) + + # === SETTINGS (режимы silence/conflict) === + + async def set_silence_mode(self, minutes: int) -> datetime: + """ + Включает режим тишины на указанное время. + + Args: + minutes: Длительность в минутах + + Returns: + datetime: Время окончания режима + """ + expires_at = datetime.now().timestamp() + (minutes * 60) + await self.repo.set_setting("silence_until", str(expires_at)) + + logger.info( + f"Режим тишины активирован на {minutes} мин", + log_type="SILENCE" + ) + + return datetime.fromtimestamp(expires_at) + + async def disable_silence_mode(self) -> None: + """Отключает режим тишины""" + await self.repo.delete_setting("silence_until") + logger.info("Режим тишины отключён", log_type="SILENCE") + + async def is_silence_active(self) -> bool: + """Проверяет, активен ли режим тишины""" + silence_until_str = await self.repo.get_setting("silence_until") + + if not silence_until_str: + return False + + try: + silence_until = float(silence_until_str) + now = datetime.now().timestamp() + + if now >= silence_until: + # Время истекло - удаляем настройку + await self.disable_silence_mode() + return False + + return True + + except (ValueError, TypeError): + return False + + async def set_conflict_mode(self, minutes: int) -> datetime: + """ + Включает режим антиконфликта на указанное время. + + Args: + minutes: Длительность в минутах + + Returns: + datetime: Время окончания режима + """ + expires_at = datetime.now().timestamp() + (minutes * 60) + await self.repo.set_setting("conflict_until", str(expires_at)) + + logger.info( + f"Режим антиконфликта активирован на {minutes} мин", + log_type="CONFLICT" + ) + + return datetime.fromtimestamp(expires_at) + + async def disable_conflict_mode(self) -> None: + """Отключает режим антиконфликта""" + await self.repo.delete_setting("conflict_until") + logger.info("Режим антиконфликта отключён", log_type="CONFLICT") + + async def is_conflict_active(self) -> bool: + """Проверяет, активен ли режим антиконфликта""" + conflict_until_str = await self.repo.get_setting("conflict_until") + + if not conflict_until_str: + return False + + try: + conflict_until = float(conflict_until_str) + now = datetime.now().timestamp() + + if now >= conflict_until: + # Время истекло + await self.disable_conflict_mode() + return False + + return True + + except (ValueError, TypeError): + return False + + # === STATISTICS === + + async def log_spam( + self, + user_id: int, + username: str, + chat_id: int, + message_text: str, + matched_word: str, + match_type: str + ) -> None: + """Логирует удаление спам-сообщения""" + await self.repo.log_spam_deletion( + user_id=user_id, + username=username, + chat_id=chat_id, + message_text=message_text, + matched_word=matched_word, + match_type=match_type + ) + + async def get_spam_stats( + self, + limit: int = 100, + user_id: Optional[int] = None + ) -> List[SpamStat]: + """Получает статистику удалений""" + return await self.repo.get_spam_stats(limit, user_id) + + async def get_user_spam_count(self, user_id: int) -> int: + """Получает количество удалённых сообщений пользователя""" + return await self.repo.get_user_spam_count(user_id) + + async def get_top_spammers(self, limit: int = 10) -> List[tuple[int, int]]: + """Получает топ спамеров""" + return await self.repo.get_top_spammers(limit) + + # === INFO === + + async def get_stats(self) -> dict: + """Получает общую статистику""" + db_stats = await self.repo.get_stats() + + # Добавляем информацию о кэше + cache_info = { + 'cache_active': self._cache_banwords is not None, + 'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None + } + + return {**db_stats, **cache_info} + + async def get_all_words_list(self) -> dict: + """ + Получает все слова для команды /listwords. + + Returns: + dict: Словарь со всеми категориями слов + """ + banwords = await self.repo.get_all_banwords() + temp_banwords = await self.repo.get_all_temp_banwords() + whitelist = await self.repo.get_whitelist() + admins = await self.repo.get_admins() + + return { + 'substring': banwords.get(BanWordType.SUBSTRING, set()), + 'lemma': banwords.get(BanWordType.LEMMA, set()), + 'part': banwords.get(BanWordType.PART, set()), + 'conflict_substring': banwords.get(BanWordType.CONFLICT_SUBSTRING, set()), + 'conflict_lemma': banwords.get(BanWordType.CONFLICT_LEMMA, set()), + 'temp_substring': temp_banwords.get(BanWordType.SUBSTRING, set()), + 'temp_lemma': temp_banwords.get(BanWordType.LEMMA, set()), + 'whitelist': whitelist, + 'admins': admins + } + + async def get_top_words(self, limit: int = 10) -> List[Dict[str, Any]]: + """ + Получает топ N самых часто срабатывающих слов. + + Args: + limit: Количество слов в топе + + Returns: + List[Dict]: Список словарей с данными: + - word: слово + - count: количество срабатываний + - type: тип проверки + """ + async with self.session_maker() as session: + try: + # Группируем по matched_word и считаем количество + query = select( + SpamLog.matched_word, + SpamLog.match_type, + func.count(SpamLog.id).label('count') + ).where( + SpamLog.matched_word.isnot(None) + ).group_by( + SpamLog.matched_word, + SpamLog.match_type + ).order_by( + desc('count') + ).limit(limit) + + result = await session.execute(query) + rows = result.all() + + # Форматируем результат + top_words = [] + for row in rows: + top_words.append({ + 'word': row.matched_word, + 'type': row.match_type, + 'count': row.count + }) + + logger.debug( + f"Получен топ-{limit} слов: {len(top_words)} записей", + log_type="DATABASE" + ) + + return top_words + + except Exception as e: + logger.error( + f"Ошибка получения топ-слов: {e}", + log_type="DATABASE" + ) + return [] + + async def get_total_spam_count(self) -> int: + """ + Получает общее количество удалённых сообщений. + + Returns: + int: Количество записей в SpamLog + """ + async with self.session_maker() as session: + try: + query = select(func.count(SpamLog.id)) + result = await session.execute(query) + count = result.scalar_one() + return count + except Exception as e: + logger.error( + f"Ошибка подсчёта спам-логов: {e}", + log_type="DATABASE" + ) + return 0 + + async def reset_spam_stats(self) -> bool: + """ + Очищает всю статистику спама. + + Returns: + bool: True если успешно + """ + async with self.session_maker() as session: + try: + # Удаляем все записи + await session.execute(delete(SpamLog)) + await session.commit() + + logger.info("Статистика спама сброшена", log_type="DATABASE") + return True + + except Exception as e: + logger.error( + f"Ошибка сброса статистики: {e}", + log_type="DATABASE" + ) + await session.rollback() + return False + + +# Глобальный экземпляр менеджера +_manager_instance: Optional[BanWordsManager] = None + + +def get_manager() -> BanWordsManager: + """ + Возвращает глобальный экземпляр BanWordsManager (Singleton). + + Returns: + BanWordsManager: Менеджер банвордов + """ + global _manager_instance + if _manager_instance is None: + _manager_instance = BanWordsManager() + return _manager_instance diff --git a/database/migrate.py b/database/migrate.py new file mode 100644 index 0000000..6ac8ee2 --- /dev/null +++ b/database/migrate.py @@ -0,0 +1,51 @@ +# Создайте файл database/migrate.py + +""" +Миграция: добавление полей matched_word и match_type в SpamLog +""" +import asyncio +from sqlalchemy import text +from .manager import get_manager + + +async def migrate(): + """Добавляет поля matched_word и match_type если их нет""" + manager = get_manager() + await manager.init() + + async with manager.session_maker() as session: + try: + # Проверяем наличие колонок + result = await session.execute( + text("PRAGMA table_info(spam_logs)") + ) + columns = [row[1] for row in result.fetchall()] + + if 'matched_word' not in columns: + print("Добавляем колонку matched_word...") + await session.execute( + text("ALTER TABLE spam_logs ADD COLUMN matched_word VARCHAR(255)") + ) + await session.commit() + print("✅ Колонка matched_word добавлена") + + if 'match_type' not in columns: + print("Добавляем колонку match_type...") + await session.execute( + text("ALTER TABLE spam_logs ADD COLUMN match_type VARCHAR(50)") + ) + await session.commit() + print("✅ Колонка match_type добавлена") + + print("✅ Миграция завершена успешно!") + + except Exception as e: + print(f"❌ Ошибка миграции: {e}") + await session.rollback() + + finally: + await manager.close() + + +if __name__ == "__main__": + asyncio.run(migrate()) diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..f0840e5 --- /dev/null +++ b/database/models.py @@ -0,0 +1,254 @@ +""" +SQLAlchemy модели для банвордов. +""" +from datetime import datetime, timezone +from enum import Enum as PyEnum +from typing import Optional + +from sqlalchemy import String, Integer, DateTime, Text, Enum, BigInteger +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +__all__ = ( + "Base", + "BanWordType", + "SpamMode", + "BanWord", + "TempBanWord", + "WhitelistWord", + "Admin", + "Setting", + "SpamStat", + "SpamLog", +) + + +class Base(DeclarativeBase): + """Базовый класс для всех моделей""" + pass + + +class BanWordType(str, PyEnum): + """Типы банвордов""" + SUBSTRING = "substring" + LEMMA = "lemma" + PART = "part" + CONFLICT_SUBSTRING = "conflict_substring" + CONFLICT_LEMMA = "conflict_lemma" + + +class SpamMode(str, PyEnum): + """Режимы работы спам-фильтра""" + NORMAL = "normal" + SILENCE = "silence" + CONFLICT = "conflict" + + +class BanWord(Base): + """ + Постоянные банворды. + + Attributes: + id: Уникальный ID + word: Само слово (lowercase) + type: Тип банворда + added_by: Telegram ID добавившего админа + added_at: Дата добавления + reason: Причина добавления + """ + __tablename__ = "banwords" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + word: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + type: Mapped[BanWordType] = mapped_column( + Enum(BanWordType, native_enum=False), + nullable=False, + index=True + ) + added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + added_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False + ) + reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + def __repr__(self) -> str: + return f"" + + +class TempBanWord(Base): + """ + Временные банворды (с автоудалением). + + Attributes: + id: Уникальный ID + word: Само слово + type: Тип банворда + added_by: ID админа + added_at: Дата добавления + expires_at: Дата истечения + """ + __tablename__ = "temp_banwords" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + word: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + type: Mapped[BanWordType] = mapped_column( + Enum(BanWordType, native_enum=False), + nullable=False + ) + added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + added_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + index=True + ) + + def is_expired(self) -> bool: + """Проверяет, истёк ли срок""" + return datetime.now() >= self.expires_at + + def __repr__(self) -> str: + return f"" + + +class WhitelistWord(Base): + """ + Белый список (исключения из проверки). + + Attributes: + id: Уникальный ID + word: Слово-исключение + added_by: ID админа + added_at: Дата добавления + reason: Причина (например, "ложное срабатывание") + """ + __tablename__ = "whitelist" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + word: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + added_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False + ) + reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + def __repr__(self) -> str: + return f"" + + +class Admin(Base): + """ + Дополнительные администраторы бота. + + Attributes: + id: Уникальный ID записи + user_id: Telegram ID пользователя (уникальный) + added_by: ID суперадмина, который добавил + added_at: Дата добавления + permissions: JSON со списком прав (для будущего) + """ + __tablename__ = "admins" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, unique=True, index=True) + added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + added_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False + ) + permissions: Mapped[Optional[str]] = mapped_column( + Text, + default="[]", + nullable=True + ) + + def __repr__(self) -> str: + return f"" + + +class Setting(Base): + """ + Настройки и состояния бота. + + Attributes: + key: Ключ настройки (primary key) + value: Значение (JSON string) + updated_at: Дата обновления + + Examples: + - silence_until: datetime ISO string + - conflict_until: datetime ISO string + - spam_mode: "normal"/"silence"/"conflict" + """ + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + onupdate=datetime.now, + nullable=False + ) + + def __repr__(self) -> str: + return f"" + + +class SpamStat(Base): + """ + Статистика удалённых спам-сообщений. + + Attributes: + id: Уникальный ID + user_id: Telegram ID отправителя + username: Username отправителя + chat_id: ID чата + message_text: Текст сообщения (до 500 символов) + matched_word: Слово, по которому сработал фильтр + match_type: Тип проверки (substring/lemma/part) + deleted_at: Дата удаления + """ + __tablename__ = "spam_stats" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + chat_id: Mapped[int] = mapped_column(Integer, nullable=False) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + matched_word: Mapped[str] = mapped_column(String(255), nullable=False) + match_type: Mapped[str] = mapped_column(String(50), nullable=False) + deleted_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.now, + nullable=False, + index=True + ) + + def __repr__(self) -> str: + return f"" + + +class SpamLog(Base): + """Модель для логирования срабатываний спам-фильтра""" + __tablename__ = "spam_logs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + username: Mapped[str] = mapped_column(String(255), nullable=True) + chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + message_text: Mapped[str] = mapped_column(Text, nullable=True) + matched_word: Mapped[str] = mapped_column(String(255), nullable=True) # <-- Должно быть! + match_type: Mapped[str] = mapped_column(String(50), nullable=True) # <-- Должно быть! + timestamp: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc) + ) diff --git a/database/repository.py b/database/repository.py new file mode 100644 index 0000000..70d084b --- /dev/null +++ b/database/repository.py @@ -0,0 +1,798 @@ +""" +Repository для работы с банвордами через SQLAlchemy ORM. +""" +from typing import Set, List, Optional +from datetime import datetime, timedelta + +from sqlalchemy import select, delete, func, and_ + +from middleware.loggers import logger +from .database import Database +from .models import ( + BanWord, + TempBanWord, + WhitelistWord, + Admin, + Setting, + SpamStat, + BanWordType +) + +__all__ = ("BanWordsRepository",) + + +class BanWordsRepository: + """ + Repository для CRUD операций с банвордами. + + Все методы работают через SQLAlchemy ORM. + """ + + def __init__(self, db: Database): + """ + Args: + db: Экземпляр Database + """ + self.db = db + + # === BANWORDS === + + async def add_banword( + self, + word: str, + word_type: BanWordType, + added_by: Optional[int] = None, + reason: Optional[str] = None + ) -> bool: + """ + Добавляет постоянный банворд. + + Args: + word: Слово для блокировки + word_type: Тип банворда + added_by: ID админа, который добавил + reason: Причина добавления + + Returns: + bool: True если добавлен, False если уже существует + """ + try: + async with self.db.get_session() as session: + # Проверяем, существует ли уже + existing = await session.execute( + select(BanWord).where( + and_( + BanWord.word == word.lower(), + BanWord.type == word_type + ) + ) + ) + if existing.scalar_one_or_none(): + return False + + # Добавляем новый + banword = BanWord( + word=word.lower(), + type=word_type, + added_by=added_by, + reason=reason + ) + session.add(banword) + await session.commit() + + logger.info( + f"Добавлен банворд: '{word}' ({word_type.value})", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка добавления банворда: {e}", + log_type="DATABASE" + ) + return False + + async def remove_banword(self, word: str, word_type: BanWordType) -> bool: + """ + Удаляет банворд. + + Args: + word: Слово + word_type: Тип + + Returns: + bool: True если удалён + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(BanWord).where( + and_( + BanWord.word == word.lower(), + BanWord.type == word_type + ) + ) + ) + await session.commit() + deleted = result.rowcount > 0 + + if deleted: + logger.info( + f"Удалён банворд: '{word}' ({word_type.value})", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка удаления банворда: {e}", + log_type="DATABASE" + ) + return False + + async def get_banwords(self, word_type: BanWordType) -> Set[str]: + """ + Получает все банворды определённого типа. + + Args: + word_type: Тип банвордов + + Returns: + Set[str]: Набор слов + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select(BanWord.word).where(BanWord.type == word_type) + ) + return set(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения банвордов: {e}", + log_type="DATABASE" + ) + return set() + + async def get_all_banwords(self) -> dict[BanWordType, Set[str]]: + """ + Получает все банворды, сгруппированные по типам. + + Returns: + dict: {BanWordType: Set[str]} + """ + result = { + BanWordType.SUBSTRING: set(), + BanWordType.LEMMA: set(), + BanWordType.PART: set(), + BanWordType.CONFLICT_SUBSTRING: set(), + BanWordType.CONFLICT_LEMMA: set(), + } + + try: + async with self.db.get_session() as session: + banwords = await session.execute(select(BanWord)) + for banword in banwords.scalars(): + result[banword.type].add(banword.word) + + except Exception as e: + logger.error( + f"Ошибка получения всех банвордов: {e}", + log_type="DATABASE" + ) + + return result + + async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]: + """ + Поиск банвордов по частичному совпадению. + + Args: + query: Поисковый запрос + limit: Максимум результатов + + Returns: + List[BanWord]: Найденные банворды + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select(BanWord) + .where(BanWord.word.contains(query.lower())) + .limit(limit) + ) + return list(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка поиска банвордов: {e}", + log_type="DATABASE" + ) + return [] + + # === TEMPORARY BANWORDS === + + async def add_temp_banword( + self, + word: str, + word_type: BanWordType, + minutes: int, + added_by: Optional[int] = None + ) -> bool: + """ + Добавляет временный банворд. + + Args: + word: Слово + word_type: Тип + minutes: Длительность в минутах + added_by: ID админа + + Returns: + bool: True если добавлен + """ + try: + async with self.db.get_session() as session: + # Проверяем существование + existing = await session.execute( + select(TempBanWord).where( + and_( + TempBanWord.word == word.lower(), + TempBanWord.type == word_type + ) + ) + ) + if existing.scalar_one_or_none(): + return False + + # Добавляем + expires_at = datetime.now() + timedelta(minutes=minutes) + temp_banword = TempBanWord( + word=word.lower(), + type=word_type, + added_by=added_by, + expires_at=expires_at + ) + session.add(temp_banword) + await session.commit() + + logger.info( + f"Добавлен временный банворд: '{word}' на {minutes} мин", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка добавления временного банворда: {e}", + log_type="DATABASE" + ) + return False + + async def remove_temp_banword(self, word: str, word_type: BanWordType) -> bool: + """Удаляет временный банворд досрочно""" + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(TempBanWord).where( + and_( + TempBanWord.word == word.lower(), + TempBanWord.type == word_type + ) + ) + ) + await session.commit() + deleted = result.rowcount > 0 + + if deleted: + logger.info( + f"Удалён временный банворд: '{word}'", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка удаления временного банворда: {e}", + log_type="DATABASE" + ) + return False + + async def get_temp_banwords(self, word_type: BanWordType) -> Set[str]: + """ + Получает активные (не истёкшие) временные банворды. + + Args: + word_type: Тип банвордов + + Returns: + Set[str]: Набор активных временных слов + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select(TempBanWord.word).where( + and_( + TempBanWord.type == word_type, + TempBanWord.expires_at > datetime.now() + ) + ) + ) + return set(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения временных банвордов: {e}", + log_type="DATABASE" + ) + return set() + + async def get_all_temp_banwords(self) -> dict[BanWordType, Set[str]]: + """Получает все активные временные банворды по типам""" + result = { + BanWordType.SUBSTRING: set(), + BanWordType.LEMMA: set(), + } + + try: + async with self.db.get_session() as session: + temp_banwords = await session.execute( + select(TempBanWord).where( + TempBanWord.expires_at > datetime.now() + ) + ) + for temp_banword in temp_banwords.scalars(): + if temp_banword.type in result: + result[temp_banword.type].add(temp_banword.word) + + except Exception as e: + logger.error( + f"Ошибка получения всех временных банвордов: {e}", + log_type="DATABASE" + ) + + return result + + async def cleanup_expired_temp_banwords(self) -> int: + """ + Удаляет истёкшие временные банворды. + + Returns: + int: Количество удалённых записей + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(TempBanWord).where( + TempBanWord.expires_at <= datetime.now() + ) + ) + await session.commit() + deleted = result.rowcount + + if deleted > 0: + logger.info( + f"Удалено {deleted} истёкших временных банвордов", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка очистки временных банвордов: {e}", + log_type="DATABASE" + ) + return 0 + + # === WHITELIST === + + async def add_whitelist( + self, + word: str, + added_by: Optional[int] = None, + reason: Optional[str] = None + ) -> bool: + """Добавляет слово в белый список (исключение)""" + try: + async with self.db.get_session() as session: + # Проверяем существование + existing = await session.execute( + select(WhitelistWord).where( + WhitelistWord.word == word.lower() + ) + ) + if existing.scalar_one_or_none(): + return False + + # Добавляем + whitelist_word = WhitelistWord( + word=word.lower(), + added_by=added_by, + reason=reason + ) + session.add(whitelist_word) + await session.commit() + + logger.info( + f"Добавлено исключение: '{word}'", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка добавления исключения: {e}", + log_type="DATABASE" + ) + return False + + async def remove_whitelist(self, word: str) -> bool: + """Удаляет слово из белого списка""" + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(WhitelistWord).where( + WhitelistWord.word == word.lower() + ) + ) + await session.commit() + deleted = result.rowcount > 0 + + if deleted: + logger.info( + f"Удалено исключение: '{word}'", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка удаления исключения: {e}", + log_type="DATABASE" + ) + return False + + async def get_whitelist(self) -> Set[str]: + """Получает все слова из белого списка""" + try: + async with self.db.get_session() as session: + result = await session.execute(select(WhitelistWord.word)) + return set(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения whitelist: {e}", + log_type="DATABASE" + ) + return set() + + # === ADMINS === + + async def add_admin( + self, + user_id: int, + added_by: Optional[int] = None + ) -> bool: + """Добавляет администратора""" + try: + async with self.db.get_session() as session: + # Проверяем существование + existing = await session.execute( + select(Admin).where(Admin.user_id == user_id) + ) + if existing.scalar_one_or_none(): + return False + + # Добавляем + admin = Admin(user_id=user_id, added_by=added_by) + session.add(admin) + await session.commit() + + logger.info( + f"Добавлен админ: {user_id}", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка добавления админа: {e}", + log_type="DATABASE" + ) + return False + + async def remove_admin(self, user_id: int) -> bool: + """Удаляет администратора""" + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(Admin).where(Admin.user_id == user_id) + ) + await session.commit() + deleted = result.rowcount > 0 + + if deleted: + logger.info( + f"Удалён админ: {user_id}", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка удаления админа: {e}", + log_type="DATABASE" + ) + return False + + async def get_admins(self) -> Set[int]: + """Получает всех администраторов""" + try: + async with self.db.get_session() as session: + result = await session.execute(select(Admin.user_id)) + return set(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения админов: {e}", + log_type="DATABASE" + ) + return set() + + async def is_admin(self, user_id: int) -> bool: + """Проверяет, является ли пользователь админом""" + try: + async with self.db.get_session() as session: + result = await session.execute( + select(Admin).where(Admin.user_id == user_id) + ) + return result.scalar_one_or_none() is not None + + except Exception as e: + logger.error( + f"Ошибка проверки админа: {e}", + log_type="DATABASE" + ) + return False + + # === SETTINGS === + + async def set_setting(self, key: str, value: str) -> None: + """ + Сохраняет настройку (или обновляет существующую). + + Args: + key: Ключ настройки + value: Значение (строка или JSON) + """ + try: + async with self.db.get_session() as session: + # Проверяем существование + existing = await session.execute( + select(Setting).where(Setting.key == key) + ) + setting = existing.scalar_one_or_none() + + if setting: + # Обновляем существующую + setting.value = value + setting.updated_at = datetime.now() + else: + # Создаём новую + setting = Setting(key=key, value=value) + session.add(setting) + + await session.commit() + + except Exception as e: + logger.error( + f"Ошибка сохранения настройки: {e}", + log_type="DATABASE" + ) + + async def get_setting( + self, + key: str, + default: Optional[str] = None + ) -> Optional[str]: + """ + Получает значение настройки. + + Args: + key: Ключ настройки + default: Значение по умолчанию + + Returns: + Optional[str]: Значение или default + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select(Setting.value).where(Setting.key == key) + ) + value = result.scalar_one_or_none() + return value if value is not None else default + + except Exception as e: + logger.error( + f"Ошибка получения настройки: {e}", + log_type="DATABASE" + ) + return default + + async def delete_setting(self, key: str) -> bool: + """Удаляет настройку""" + try: + async with self.db.get_session() as session: + result = await session.execute( + delete(Setting).where(Setting.key == key) + ) + await session.commit() + return result.rowcount > 0 + + except Exception as e: + logger.error( + f"Ошибка удаления настройки: {e}", + log_type="DATABASE" + ) + return False + + # === STATISTICS === + + async def log_spam_deletion( + self, + user_id: int, + username: str, + chat_id: int, + message_text: str, + matched_word: str, + match_type: str + ) -> None: + """ + Записывает статистику удалённого спам-сообщения. + + Args: + user_id: Telegram ID отправителя + username: Username отправителя + chat_id: ID чата + message_text: Текст сообщения (обрезается до 500 символов) + matched_word: Слово, по которому сработал фильтр + match_type: Тип проверки (substring/lemma/part/silence/conflict) + """ + try: + async with self.db.get_session() as session: + spam_stat = SpamStat( + user_id=user_id, + username=username, + chat_id=chat_id, + message_text=message_text[:500], + matched_word=matched_word, + match_type=match_type + ) + session.add(spam_stat) + await session.commit() + + except Exception as e: + logger.error( + f"Ошибка логирования статистики: {e}", + log_type="DATABASE" + ) + + async def get_spam_stats( + self, + limit: int = 100, + user_id: Optional[int] = None + ) -> List[SpamStat]: + """ + Получает последнюю статистику удалений. + + Args: + limit: Максимум записей + user_id: Фильтр по пользователю (опционально) + + Returns: + List[SpamStat]: Список записей статистики + """ + try: + async with self.db.get_session() as session: + query = select(SpamStat).order_by(SpamStat.deleted_at.desc()) + + if user_id: + query = query.where(SpamStat.user_id == user_id) + + query = query.limit(limit) + result = await session.execute(query) + return list(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения статистики: {e}", + log_type="DATABASE" + ) + return [] + + async def get_user_spam_count(self, user_id: int) -> int: + """Получает количество удалённых сообщений пользователя""" + try: + async with self.db.get_session() as session: + result = await session.execute( + select(func.count(SpamStat.id)).where( + SpamStat.user_id == user_id + ) + ) + return result.scalar_one() + + except Exception as e: + logger.error( + f"Ошибка подсчёта спама: {e}", + log_type="DATABASE" + ) + return 0 + + async def get_top_spammers(self, limit: int = 10) -> List[tuple[int, int]]: + """ + Получает топ спамеров. + + Args: + limit: Количество записей + + Returns: + List[tuple[int, int]]: [(user_id, count), ...] + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select( + SpamStat.user_id, + func.count(SpamStat.id).label('count') + ) + .group_by(SpamStat.user_id) + .order_by(func.count(SpamStat.id).desc()) + .limit(limit) + ) + return [(row.user_id, row.count) for row in result] + + except Exception as e: + logger.error( + f"Ошибка получения топ спамеров: {e}", + log_type="DATABASE" + ) + return [] + + # === GENERAL === + + async def get_stats(self) -> dict: + """Получает общую статистику БД""" + try: + async with self.db.get_session() as session: + banwords_count = await session.execute( + select(func.count(BanWord.id)) + ) + temp_banwords_count = await session.execute( + select(func.count(TempBanWord.id)) + ) + whitelist_count = await session.execute( + select(func.count(WhitelistWord.id)) + ) + admins_count = await session.execute( + select(func.count(Admin.id)) + ) + spam_stats_count = await session.execute( + select(func.count(SpamStat.id)) + ) + + return { + 'banwords': banwords_count.scalar_one(), + 'temp_banwords': temp_banwords_count.scalar_one(), + 'whitelist': whitelist_count.scalar_one(), + 'admins': admins_count.scalar_one(), + 'spam_deletions': spam_stats_count.scalar_one(), + } + + except Exception as e: + logger.error( + f"Ошибка получения статистики: {e}", + log_type="DATABASE" + ) + return {} diff --git a/locales/en/LC_MESSAGES/bot.mo b/locales/en/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..fc24c7e Binary files /dev/null and b/locales/en/LC_MESSAGES/bot.mo differ diff --git a/locales/en/LC_MESSAGES/bot.po b/locales/en/LC_MESSAGES/bot.po new file mode 100644 index 0000000..76c2814 --- /dev/null +++ b/locales/en/LC_MESSAGES/bot.po @@ -0,0 +1,58 @@ +# English translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/messages.pot b/locales/messages.pot new file mode 100644 index 0000000..1bfd65d --- /dev/null +++ b/locales/messages.pot @@ -0,0 +1,56 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.13.1\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/ru/LC_MESSAGES/bot.mo b/locales/ru/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..bc4eb55 Binary files /dev/null and b/locales/ru/LC_MESSAGES/bot.mo differ diff --git a/locales/ru/LC_MESSAGES/bot.po b/locales/ru/LC_MESSAGES/bot.po new file mode 100644 index 0000000..82e0714 --- /dev/null +++ b/locales/ru/LC_MESSAGES/bot.po @@ -0,0 +1,59 @@ +# Russian translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/uk/LC_MESSAGES/bot.mo b/locales/uk/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..914b389 Binary files /dev/null and b/locales/uk/LC_MESSAGES/bot.mo differ diff --git a/locales/uk/LC_MESSAGES/bot.po b/locales/uk/LC_MESSAGES/bot.po new file mode 100644 index 0000000..39c6457 --- /dev/null +++ b/locales/uk/LC_MESSAGES/bot.po @@ -0,0 +1,59 @@ +# Ukrainian translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: uk\n" +"Language-Team: uk \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/main.py b/main.py new file mode 100644 index 0000000..f040a64 --- /dev/null +++ b/main.py @@ -0,0 +1,172 @@ +""" +Точка входа PrimoGuard Bot +""" +from asyncio import run + +from configs import settings +from bot import bot, dp, BotInfo, WebhookManager, setup_middlewares, router +from database import get_manager +from middleware.loggers import logger + +__all__ = ("main",) + + +async def setup_services(setup_webhook: bool = True) -> str: + """ + Инициализация всех сервисов: БД и бот. + + Args: + setup_webhook: Устанавливать ли webhook в BotInfo.setup() + + Returns: + str: Username бота + """ + # База данных + manager = get_manager() + await manager.init() + + stats = await manager.get_stats() + logger.info( + f"📊 БД инициализирована: {stats.get('total_banwords', 0)} банвордов", + log_type="DATABASE" + ) + + # Бот: получение информации (БЕЗ webhook) + await BotInfo.setup(bots=bot, setup_webhook=setup_webhook) + + # ВАЖНО: Регистрируем middleware и роутеры ДО установки webhook + setup_middlewares( + dp=dp, + bot=bot, + enable_spam_check=settings.ANTI_SPAM, + channel_ids=[], + ) + + # Подключение маршрутов + dp.include_router(router) + + logger.info("✓ Все handlers и middleware зарегистрированы", log_type="STARTUP") + + return BotInfo.username + + +async def on_startup(app) -> None: + """Выполняется при запуске webhook-сервера.""" + # 1. Инициализируем всё БЕЗ webhook + username = await setup_services(setup_webhook=False) + + # 2. ТЕПЕРЬ устанавливаем webhook (когда всё готово) + webhook = WebhookManager(bot, dp) + + if settings.WEBHOOK_URL: + success = await webhook.setup( + webhook_url=settings.WEBHOOK_URL, + secret_token=settings.SECRET_TOKEN, + drop_pending_updates=True + ) + + if success: + logger.success( + f"✅ Бот @{username} запущен в режиме Webhook", + log_type="STARTUP" + ) + else: + logger.error( + "❌ Не удалось установить webhook, но сервер запущен", + log_type="STARTUP" + ) + else: + logger.warning( + "⚠️ WEBHOOK_URL не указан", + log_type="STARTUP" + ) + + +async def on_shutdown(app) -> None: + """Очистка ресурсов при остановке сервера.""" + logger.info("👋 Остановка бота...", log_type="SHUTDOWN") + + try: + await get_manager().close() + await bot.session.close() + except Exception as e: + logger.error(f"Ошибка при закрытии: {e}", log_type="SHUTDOWN") + + logger.success("✅ Бот остановлен", log_type="SHUTDOWN") + + +def start_webhook() -> None: + """Запуск в режиме Webhook (синхронный).""" + logger.setup() + + webhook = WebhookManager(bot, dp) + + # ВАЖНО: Конфигурируем webhook ПЕРЕД on_startup + webhook.configure() + + # Добавляем startup/shutdown handlers + webhook.app.on_startup.append(on_startup) + webhook.app.on_shutdown.append(on_shutdown) + + # Запускаем webhook сервер + logger.info("🌐 Запуск Webhook сервера...", log_type="MAIN") + webhook.run() + + +async def start_polling() -> None: + """Запуск в режиме Polling (асинхронный).""" + logger.setup() + + try: + username = await setup_services(setup_webhook=False) + + # Удаляем webhook для polling режима + webhook = WebhookManager(bot, dp) + await webhook.delete(drop_pending_updates=True) + + logger.success( + f"✅ Бот @{username} запущен в режиме Polling", + log_type="STARTUP" + ) + + # Запускаем polling + await dp.start_polling(bot, drop_pending_updates=True) + + except Exception as e: + logger.critical( + f"🔥 Критическая ошибка: {e}", + log_type="MAIN" + ) + raise + + finally: + try: + await bot.session.close() + await get_manager().close() + except: + pass + + +def main() -> None: + """Входная точка проекта.""" + try: + if settings.WEBHOOK: + # ========== WEBHOOK РЕЖИМ ========== + start_webhook() + else: + # ========== POLLING РЕЖИМ ========== + run(start_polling()) + + except KeyboardInterrupt: + logger.info("⚠️ Остановка по сигналу Ctrl+C", log_type="MAIN") + + except Exception as e: + logger.critical( + f"🔥 Критическая ошибка при запуске: {e}", + log_type="MAIN" + ) + raise + + +if __name__ == "__main__": + main() diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..e719cd0 --- /dev/null +++ b/middleware/__init__.py @@ -0,0 +1,2 @@ +from .loggers import * +from .validators import * diff --git a/middleware/loggers/__init__.py b/middleware/loggers/__init__.py new file mode 100644 index 0000000..d668488 --- /dev/null +++ b/middleware/loggers/__init__.py @@ -0,0 +1 @@ +from .logs import * diff --git a/middleware/loggers/logs.py b/middleware/loggers/logs.py new file mode 100644 index 0000000..f6a0479 --- /dev/null +++ b/middleware/loggers/logs.py @@ -0,0 +1,364 @@ +""" +Кастомный логгер с поддержкой декораторов и прямого вызова +""" +from sys import stderr +from pathlib import Path +from functools import wraps +from inspect import iscoroutinefunction +from typing import Any, Callable, Optional, TypeVar, Union, cast, Final +from contextlib import contextmanager + +from loguru import logger as nlogger +from aiogram.types import Message, User, CallbackQuery + +from configs import settings + +# Экспортируемые объекты +__all__ = ('Logger', 'setup_logging', 'logger', 'log') + +# Универсальный тип для функций +F = TypeVar('F', bound=Callable[..., Any]) + +# Типы для aiogram событий +EventType = Union[Message, CallbackQuery] + + +class Logger: + """ + Кастомный логгер с поддержкой декораторов, прямого вызова и контекстных менеджеров. + + Features: + - Декораторы для sync/async функций + - Прямой вызов методов (debug, info, warning, error, critical) + - Автоматическое извлечение юзера из Message/CallbackQuery + - Раздельные файлы логов по уровням + - Ротация и retention логов + - Контекстные менеджеры для блоков кода + """ + + # Формат логов + _log_format: Final[str] = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' + '{extra[system]}-{extra[log_type]} | ' + '{extra[user]} | {message}' + ) + + + def __init__(self, system_name: str = 'PRIMO') -> None: + """ + Инициализация логгера. + + Args: + system_name: Имя системы для логирования (по умолчанию из settings) + """ + self.system_name = system_name or settings.PROJECT_NAME + self._setup_done = False + + def setup(self, start: bool = True) -> None: + """ + Настройка обработчиков Loguru: консоль и файлы. + + Args: + start: Если True, сразу логирует запуск проекта + """ + if self._setup_done: + return + + # Полная очистка настроек + nlogger.remove() + + # Создание директории для файловых логов + log_dir: Path = settings.LOG_DIR + if not log_dir.exists(): + log_dir.mkdir(parents=True, exist_ok=True) + + # Консольный лог + if settings.LOG_CONSOLE: + nlogger.add( + sink=stderr, + format=self._log_format, + colorize=True, + level='INFO', + filter=lambda rec: rec['extra'].get('log_type') != 'TRACE' + ) + + # Файловые логи + if settings.LOG_FILE: + # Общий лог со всеми уровнями + nlogger.add( + sink=log_dir / 'bot.log', + rotation=settings.LOG_ROTATION, + retention=settings.LOG_RETENTION, + format=self._log_format, + level='DEBUG', + enqueue=True, + backtrace=True, + diagnose=True, + encoding='utf-8' + ) + + # Раздельные логи по уровням + log_levels = { + 'INFO': 'info.log', + 'WARNING': 'warning.log', + 'ERROR': 'error.log', + 'CRITICAL': 'critical.log' + } + + + for level_name, filename in log_levels.items(): + nlogger.add( + sink=log_dir / filename, + rotation='10 MB', + retention=settings.LOG_RETENTION, + format=self._log_format, + level=level_name, + filter=lambda rec, lvl=level_name: rec['level'].name == lvl, + enqueue=True, + encoding='utf-8' + ) + + self._setup_done = True + + # Логируем старт + if start: + self.log_entry( + level='INFO', + text=f'Запуск проекта {self.system_name}...', + log_type='START' + ) + + + @staticmethod + def format_user(event: Optional[EventType] = None) -> str: + """ + Форматирует имя пользователя из объекта Message или CallbackQuery. + + Args: + event: Объект Message или CallbackQuery + + Returns: + str: Строка '@username' или 'id' или '@System' + """ + if event is None: + return '@System' + + # Извлекаем пользователя из Message или CallbackQuery + user: Optional[User] = None + if isinstance(event, Message): + user = event.from_user + elif isinstance(event, CallbackQuery): + user = event.from_user + + if user is None: + return '@System' + + return f"@{user.username}" if user.username else f"id{user.id}" + + def log_entry( + self, + level: str, + text: str, + log_type: str = 'SYSTEM', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """ + Основной метод для записи логов. + + Args: + level: Уровень логирования ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + text: Сообщение для логирования + log_type: Кастомный тип лога (например, 'HANDLER', 'COMMAND', 'SPAM') + user: Явно указанный пользователь (если None, извлекается из message) + message: Объект Message/CallbackQuery для извлечения юзера + """ + actual_user: str = user or self.format_user(message) + nlogger.bind( + system=self.system_name, + user=actual_user, + log_type=log_type + ).log(level, text) + + def log( + self, + level: str = 'INFO', + log_type: str = 'HANDLER', + text: Optional[str] = None + ) -> Callable[[F], F]: + """ + Декоратор для логирования функций (sync и async). + + Args: + level: Уровень логирования + log_type: Категория лога + text: Кастомный текст сообщения (если None, используется имя функции) + + Returns: + Декорированную функцию + + Example: + ```python + @logger.log(level='INFO', log_type='COMMAND', text='Команда /start') + async def start_handler(message: Message): + await message.answer("Привет!") + ``` + """ + + def decorator(func: F) -> F: + is_coroutine = iscoroutinefunction(func) + action_text = text or f'Вызов {func.__name__}' + + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + message = self._find_event(args) + self.log_entry(level, f"[▶] {action_text}", log_type, message=message) + try: + result = func(*args, **kwargs) + self.log_entry(level, f"[✓] {action_text}", log_type, message=message) + return result + except Exception as e: + self.log_entry( + 'ERROR', + f"[✗] {action_text} | Exception: {e!r}", + log_type, + message=message + ) + raise + + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + message = self._find_event(args) + self.log_entry(level, f"[▶] {action_text}", log_type, message=message) + try: + result = await func(*args, **kwargs) + self.log_entry(level, f"[✓] {action_text}", log_type, message=message) + return result + except Exception as e: + self.log_entry( + 'ERROR', + f"[✗] {action_text} | Exception: {e!r}", + log_type, + message=message + ) + raise + + return cast(F, async_wrapper if is_coroutine else sync_wrapper) + + return decorator + + @staticmethod + def _find_event(args: tuple[Any, ...]) -> Optional[EventType]: + """ + Ищет объект Message или CallbackQuery в аргументах функции. + + Args: + args: Аргументы функции + + Returns: + Найденный Message/CallbackQuery или None + """ + for arg in args: + if isinstance(arg, (Message, CallbackQuery)): + return arg + return None + + # ================= МЕТОДЫ ДЛЯ ПРЯМОГО ВЫЗОВА ================= + + def debug( + self, + text: str, + log_type: str = 'DEBUG', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование уровня DEBUG""" + self.log_entry('DEBUG', text, log_type, user, message) + + def info( + self, + text: str, + log_type: str = 'INFO', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование уровня INFO""" + self.log_entry('INFO', text, log_type, user, message) + + def warning( + self, + text: str, + log_type: str = 'WARNING', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование уровня WARNING""" + self.log_entry('WARNING', text, log_type, user, message) + + def error( + self, + text: str, + log_type: str = 'ERROR', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование уровня ERROR""" + self.log_entry('ERROR', text, log_type, user, message) + + def critical( + self, + text: str, + log_type: str = 'CRITICAL', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование уровня CRITICAL""" + self.log_entry('CRITICAL', text, log_type, user, message) + + def success( + self, + text: str, + log_type: str = 'SUCCESS', + user: Optional[str] = None, + message: Optional[EventType] = None + ) -> None: + """Логирование успешного выполнения (уровень INFO)""" + self.log_entry('INFO', f"✓ {text}", log_type, user, message) + + # ================= КОНТЕКСТНЫЕ МЕНЕДЖЕРЫ ================= + + @contextmanager + def log_context( + self, + action: str, + log_type: str = 'CONTEXT', + level: str = 'INFO', + message: Optional[EventType] = None + ): + """ + Контекстный менеджер для логирования блока кода. + + Example: + ```python + with logger.log_context("Обработка данных", log_type='DATA'): + # ... ваш код ... + pass + ``` + """ + self.log_entry(level, f"[▶] {action}", log_type, message=message) + try: + yield + self.log_entry(level, f"[✓] {action}", log_type, message=message) + except Exception as e: + self.log_entry('ERROR', f"[✗] {action} | Exception: {e!r}", log_type, message=message) + raise + + +# ================= ГЛОБАЛЬНЫЙ ЭКЗЕМПЛЯР ================= + +# Создаем глобальный экземпляр логгера +logger: Logger = Logger(system_name="PRIMO") + +# Алиасы для обратной совместимости +setup_logging = logger.setup +log = logger.log diff --git a/middleware/validators/__init__.py b/middleware/validators/__init__.py new file mode 100644 index 0000000..98878dc --- /dev/null +++ b/middleware/validators/__init__.py @@ -0,0 +1,2 @@ +from .email_vld import * +from .url_vld import * diff --git a/middleware/validators/email_vld.py b/middleware/validators/email_vld.py new file mode 100644 index 0000000..5288517 --- /dev/null +++ b/middleware/validators/email_vld.py @@ -0,0 +1,24 @@ +from typing import Optional + +from email_validator import validate_email, EmailNotValidError, ValidatedEmail + +# Настройка экспорта из этого модуля +__all__ = ("valid_email",) + + +def valid_email(email: str) -> Optional[str]: + """ + Валидация почты через библиотеку. + + :param email: Получаемая почта. + :return: Нормализированная почта. + """ + try: + # Провека почты на валидность + email: ValidatedEmail = validate_email(email) + + except EmailNotValidError: + return None + + # Возвращение строки с нормализированной почтой + return email.normalized diff --git a/middleware/validators/url_vld.py b/middleware/validators/url_vld.py new file mode 100644 index 0000000..6da66da --- /dev/null +++ b/middleware/validators/url_vld.py @@ -0,0 +1,42 @@ +from re import Pattern, compile + +# Настройка экспорта +__all__ = ("valid_url", "url_to_text",) + + +def valid_url(url: str) -> bool: + """ + Проверяет, является ли строка валидной ссылкой (URL). + + :param url: Строка для проверки. + :return: True, если строка является валидным URL, иначе False. + """ + url_pattern: Pattern[str] = compile( + r'^(https?://)?' # Протокол (http или https, необязателен) + r'([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}' # Домен + r'(:\d+)?' # Порт (необязателен) + r'(/[-a-zA-Z0-9@:%_+.~#?&/=]*)?$' # Путь, параметры и фрагменты + ) + return bool(url_pattern.match(url)) + + +def url_to_text(text: str, url: str) -> str: + """ + Преобразует текст в HTML ссылку с указанным URL. + + Эта функция генерирует HTML-ссылку с переданным текстом и URL, используя тег `<а>`, и делает ссылку жирной. + + :param text: Текст, который будет отображаться для ссылки. + :param url: URL, который будет привязан к тексту. + :return: Строка с HTML кодом для ссылки, если URL валиден. + :raises ValueError: Если URL невалиден. + """ + try: + if not valid_url(url): # Проверяем, является ли URL валидным + raise ValueError(f"Переданный URL '{url}' невалиден.") + + # Генерация HTML-ссылки + return f'{text}' + + except ValueError as e: + raise e # Перебрасываем ошибку выше для дальнейшей обработки или уведомления diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4385194 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "primoexamplebot" +version = "0.1.0" +description = "none" +authors = [ + {name = "admin",email = "inkscaper0349@outlook.com"} +] +license = {text = "MIT License"} +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "aiogram (>=3.22.0,<4.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "uvicorn (>=0.35.0,<0.36.0)", + "fastapi (>=0.116.1,<0.117.0)", + "pydantic-settings (>=2.10.1,<3.0.0)", + "sqlalchemy (>=2.0.43,<3.0.0)", + "babel (>=2.17.0,<3.0.0)", + "aiosqlite (>=0.21.0,<0.22.0)", + "email-validator (>=2.3.0,<3.0.0)", + "apscheduler (>=3.11.0,<4.0.0)", + "pymorphy3 (>=2.0.6,<3.0.0)", +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry] +package-mode = false + +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.1" +pytest-asyncio = "^1.1.0" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*"