Первый коммит
This commit is contained in:
52
.dockerignore
Normal file
52
.dockerignore
Normal file
@@ -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
|
||||
253
.env_example
Normal file
253
.env_example
Normal file
@@ -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
|
||||
97
.gitattributes
vendored
Normal file
97
.gitattributes
vendored
Normal file
@@ -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
|
||||
71
.gitignore
vendored
Normal file
71
.gitignore
vendored
Normal file
@@ -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
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||
29
.idea/PrimoGuardBot.iml
generated
Normal file
29
.idea/PrimoGuardBot.iml
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/core" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/filters" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/handlers/messages" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/keyboards" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/middlewares" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/states" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/templates" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/bot/utils" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/configs" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/middleware" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/middleware/loggers" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/middleware/validators" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 (PrimoGuardBot)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/locales" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="HttpUrlsUsage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/PrimoGuardBot.iml" filepath="$PROJECT_DIR$/.idea/PrimoGuardBot.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
BIN
assets/photo/default.jpg
Normal file
BIN
assets/photo/default.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 554 KiB |
BIN
assets/photo/start.jpg
Normal file
BIN
assets/photo/start.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 430 KiB |
4
bot/__init__.py
Normal file
4
bot/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .core import *
|
||||
from .handlers import router
|
||||
from .middlewares import *
|
||||
from .filters import *
|
||||
5
bot/core/__init__.py
Normal file
5
bot/core/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Модуль управления ботом
|
||||
"""
|
||||
from .bots import *
|
||||
from .webhook import *
|
||||
398
bot/core/bots.py
Normal file
398
bot/core/bots.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Ядро PrimoGuard Bot: Инициализация, Управление и Информация
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription
|
||||
from aiogram.utils.i18n import I18n, SimpleI18nMiddleware
|
||||
from pymorphy3 import MorphAnalyzer
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('bot', 'dp', 'storage', 'i18n', 'morph', 'BotInfo')
|
||||
|
||||
|
||||
# ================= STORAGE И DISPATCHER =================
|
||||
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
dp["is_active"] = True
|
||||
|
||||
|
||||
# ================= ИНТЕРНАЦИОНАЛИЗАЦИЯ =================
|
||||
|
||||
i18n = I18n(path="locales", default_locale="ru", domain="bot")
|
||||
i18n_middleware = SimpleI18nMiddleware(i18n=i18n)
|
||||
i18n_middleware.setup(dp)
|
||||
|
||||
|
||||
# ================= БОТ =================
|
||||
|
||||
bot = Bot(
|
||||
token=settings.active_bot_token,
|
||||
default=DefaultBotProperties(
|
||||
parse_mode=settings.PARSE_MODE,
|
||||
disable_notification=settings.DISABLE_NOTIFICATION,
|
||||
protect_content=settings.PROTECT_CONTENT,
|
||||
allow_sending_without_reply=settings.ALLOW_SENDING_WITHOUT_REPLY,
|
||||
link_preview_is_disabled=settings.LINK_PREVIEW_IS_DISABLED,
|
||||
link_preview_prefer_small_media=settings.LINK_PREVIEW_PREFER_SMALL_MEDIA,
|
||||
link_preview_prefer_large_media=settings.LINK_PREVIEW_PREFER_LARGE_MEDIA,
|
||||
link_preview_show_above_text=settings.LINK_PREVIEW_SHOW_ABOVE_TEXT,
|
||||
show_caption_above_media=settings.SHOW_CAPTION_ABOVE_MEDIA
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ================= МОРФОАНАЛИЗАТОР =================
|
||||
|
||||
morph = MorphAnalyzer()
|
||||
|
||||
|
||||
# ================= КЛАСС УПРАВЛЕНИЯ БОТОМ =================
|
||||
|
||||
class BotInfo:
|
||||
"""Класс для хранения данных и управления ботом"""
|
||||
|
||||
# Основные данные бота
|
||||
id: int = None
|
||||
url: str = None
|
||||
first_name: str = None
|
||||
last_name: str = None
|
||||
username: str = None
|
||||
description: str = None
|
||||
short_description: str = None
|
||||
is_premium: bool = False
|
||||
|
||||
# Возможности бота
|
||||
can_join_groups: bool = False
|
||||
can_read_all_group_messages: bool = False
|
||||
supports_inline_queries: bool = False
|
||||
can_connect_to_business: bool = False
|
||||
has_main_web_app: bool = False
|
||||
added_to_attachment_menu: bool = False
|
||||
|
||||
# Данные из конфига
|
||||
prefix: str = settings.PREFIX
|
||||
started_at: datetime = None
|
||||
|
||||
@classmethod
|
||||
def mention(cls) -> str:
|
||||
"""Упоминание бота"""
|
||||
return f'@{cls.username}' if cls.username else f'id{cls.id}'
|
||||
|
||||
@classmethod
|
||||
async def webhook(cls, bots: Bot = bot) -> None:
|
||||
"""
|
||||
Настраивает webhook для бота.
|
||||
|
||||
Args:
|
||||
bots: Объект бота для управления
|
||||
"""
|
||||
# Только если включен режим webhook
|
||||
if not settings.WEBHOOK:
|
||||
logger.debug("Режим Webhook отключен (WEBHOOK=False)", log_type='WEBHOOK')
|
||||
return
|
||||
|
||||
# Проверяем наличие URL
|
||||
if not settings.WEBHOOK_URL:
|
||||
logger.warning(
|
||||
"⚠️ WEBHOOK_URL не указан в настройках",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("Настройка вебхука бота", log_type='BOT')
|
||||
|
||||
# Проверяем текущий webhook
|
||||
current_info = await bots.get_webhook_info()
|
||||
|
||||
# Если уже установлен нужный URL, пропускаем
|
||||
if current_info.url == settings.WEBHOOK_URL:
|
||||
logger.info(
|
||||
f"✓ Вебхук уже установлен: {settings.WEBHOOK_URL}",
|
||||
log_type='BOT'
|
||||
)
|
||||
return
|
||||
|
||||
# Устанавливаем webhook
|
||||
await bots.set_webhook(
|
||||
url=settings.WEBHOOK_URL,
|
||||
secret_token=settings.SECRET_TOKEN,
|
||||
drop_pending_updates=True
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"✓ Вебхук установлен: {settings.WEBHOOK_URL}",
|
||||
log_type='BOT'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"❌ Ошибка установки вебхука: {e}",
|
||||
log_type='BOT'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def info(cls, bots: Bot = bot) -> dict:
|
||||
"""
|
||||
Получает и сохраняет информацию о боте.
|
||||
|
||||
:param bots: Объект бота для управления
|
||||
:return: Словарь с данными о боте
|
||||
"""
|
||||
logger.info("Получение информации о боте", log_type='BOT')
|
||||
|
||||
bot_info: User = await bots.get_me()
|
||||
|
||||
cls.id = bot_info.id
|
||||
cls.url = f'tg://user?id={cls.id}'
|
||||
cls.first_name = bot_info.first_name
|
||||
cls.last_name = bot_info.last_name
|
||||
cls.username = bot_info.username
|
||||
cls.can_join_groups = getattr(bot_info, 'can_join_groups', False)
|
||||
cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False)
|
||||
cls.supports_inline_queries = bot_info.supports_inline_queries or False
|
||||
cls.can_connect_to_business = bot_info.can_connect_to_business or False
|
||||
cls.has_main_web_app = bot_info.has_main_web_app or False
|
||||
cls.added_to_attachment_menu = bot_info.added_to_attachment_menu or False
|
||||
cls.started_at = datetime.now()
|
||||
|
||||
logger.success(f"Информация о боте @{cls.username} получена", log_type='BOT')
|
||||
|
||||
return {
|
||||
'id': cls.id,
|
||||
'url': cls.url,
|
||||
'first_name': cls.first_name,
|
||||
'last_name': cls.last_name,
|
||||
'username': cls.username,
|
||||
'prefix': cls.prefix,
|
||||
'is_premium': cls.is_premium,
|
||||
'can_join_groups': cls.can_join_groups,
|
||||
'can_read_all_group_messages': cls.can_read_all_group_messages,
|
||||
'supports_inline_queries': cls.supports_inline_queries,
|
||||
'can_connect_to_business': cls.can_connect_to_business,
|
||||
'has_main_web_app': cls.has_main_web_app,
|
||||
'added_to_attachment_menu': cls.added_to_attachment_menu,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def set_name(bots: Bot = bot, new_name: str = None) -> bool:
|
||||
"""Устанавливает имя бота"""
|
||||
new_name = new_name or settings.BOT_NAME
|
||||
|
||||
if not (1 <= len(new_name) <= 64):
|
||||
logger.error(f"Имя бота должно быть от 1 до 64 символов (текущее: {len(new_name)})", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
try:
|
||||
current_name = (await bots.get_me()).first_name
|
||||
|
||||
if current_name == new_name:
|
||||
logger.debug(f"Имя бота уже установлено: '{current_name}'", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
await bots.set_my_name(new_name)
|
||||
logger.success(f"Имя бота изменено: '{current_name}' → '{new_name}'", log_type='BOT_SETUP')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки имени бота: {e}", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def set_description(bots: Bot = bot, new_description: str = None) -> bool:
|
||||
"""Устанавливает полное описание бота"""
|
||||
new_description = new_description or settings.BOT_DESCRIPTION
|
||||
|
||||
if not (0 < len(new_description) <= 512):
|
||||
logger.error(f"Описание должно быть от 1 до 512 символов (текущее: {len(new_description)})", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
try:
|
||||
current_description: BotDescription = await bots.get_my_description()
|
||||
current_text = current_description.description if current_description else ""
|
||||
|
||||
if current_text == new_description:
|
||||
logger.debug("Описание бота уже установлено", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
await bots.set_my_description(description=new_description)
|
||||
logger.success("Описание бота обновлено", log_type='BOT_SETUP')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки описания бота: {e}", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def set_short_description(bots: Bot = bot, new_short: str = None) -> bool:
|
||||
"""Устанавливает короткое описание бота"""
|
||||
new_short = new_short or settings.BOT_SHORT_DESCRIPTION
|
||||
|
||||
if not (0 < len(new_short) <= 120):
|
||||
logger.error(f"Короткое описание должно быть от 1 до 120 символов (текущее: {len(new_short)})", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
try:
|
||||
current_short: BotShortDescription = await bots.get_my_short_description()
|
||||
current_text = current_short.short_description if current_short else ""
|
||||
|
||||
if current_text == new_short:
|
||||
logger.debug("Короткое описание бота уже установлено", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
await bots.set_my_short_description(short_description=new_short)
|
||||
logger.success("Короткое описание бота обновлено", log_type='BOT_SETUP')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки короткого описания: {e}", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = None) -> bool:
|
||||
"""Устанавливает права администратора по умолчанию"""
|
||||
rights = rights or settings.rights
|
||||
|
||||
try:
|
||||
current_rights = await bots.get_my_default_administrator_rights()
|
||||
|
||||
if current_rights == rights:
|
||||
logger.debug("Права администратора уже установлены", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
await bots.set_my_default_administrator_rights(rights)
|
||||
logger.success("Права администратора обновлены", log_type='BOT_SETUP')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки прав администратора: {e}", log_type='BOT_SETUP')
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def print(cls, to_console: bool = True, to_file: bool = True) -> str:
|
||||
"""
|
||||
Красиво форматирует и выводит информацию о боте.
|
||||
|
||||
:param to_console: Вывести в консоль
|
||||
:param to_file: Записать в файлы
|
||||
:return: Отформатированная строка
|
||||
"""
|
||||
# Формирование блоков информации
|
||||
header = f"╔═══════════════════════════════════════════════════════════╗"
|
||||
title = f"║ 🤖 PRIMOGUARD BOT - ИНФОРМАЦИЯ О ЗАПУСКЕ ║"
|
||||
separator = f"╠═══════════════════════════════════════════════════════════╣"
|
||||
footer = f"╚═══════════════════════════════════════════════════════════╝"
|
||||
|
||||
lines = [
|
||||
header,
|
||||
title,
|
||||
separator,
|
||||
f"║ ⏰ Время запуска: {cls.started_at.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
f"║",
|
||||
f"║ 📋 ОСНОВНАЯ ИНФОРМАЦИЯ:",
|
||||
f"║ • Имя: {cls.first_name} {cls.last_name or ''}".ljust(60) + "║",
|
||||
f"║ • Username: @{cls.username}".ljust(60) + "║",
|
||||
f"║ • ID: {cls.id}".ljust(60) + "║",
|
||||
f"║",
|
||||
f"║ ⚙️ ВОЗМОЖНОСТИ БОТА:",
|
||||
f"║ • Вступать в группы: {'✅' if cls.can_join_groups else '❌'}".ljust(60) + "║",
|
||||
f"║ • Читать все сообщения: {'✅' if cls.can_read_all_group_messages else '❌'}".ljust(60) + "║",
|
||||
f"║ • Инлайн-запросы: {'✅' if cls.supports_inline_queries else '❌'}".ljust(60) + "║",
|
||||
f"║ • Бизнес-аккаунты: {'✅' if cls.can_connect_to_business else '❌'}".ljust(60) + "║",
|
||||
f"║ • Веб-приложение: {'✅' if cls.has_main_web_app else '❌'}".ljust(60) + "║",
|
||||
f"║ • Меню вложений: {'✅' if cls.added_to_attachment_menu else '❌'}".ljust(60) + "║",
|
||||
f"║",
|
||||
f"║ 🔧 НАСТРОЙКИ:",
|
||||
f"║ • Префикс команд: {cls.prefix}".ljust(60) + "║",
|
||||
f"║ • Режим: {'Webhook' if settings.WEBHOOK else 'Polling'}".ljust(60) + "║",
|
||||
footer
|
||||
]
|
||||
|
||||
output = '\n'.join(lines)
|
||||
|
||||
# Вывод в консоль с цветом
|
||||
if to_console and settings.START_INFO_CONSOLE:
|
||||
colored_output = f"\033[96m{output}\033[0m" # Cyan цвет
|
||||
print(colored_output)
|
||||
|
||||
# Запись в файлы
|
||||
if to_file and settings.START_INFO_TO_FILE:
|
||||
try:
|
||||
settings.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Полная информация в bot_info.log
|
||||
info_file = settings.LOG_DIR / 'bot_info.log'
|
||||
with open(info_file, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
|
||||
# Краткая запись в историю запусков
|
||||
start_file = settings.LOG_DIR / 'bot_starts.log'
|
||||
with open(start_file, 'a', encoding='utf-8') as f:
|
||||
start_entry = f"{cls.started_at.strftime('%d.%m.%Y %H:%M:%S')} | @{cls.username} | Mode: {'Webhook' if settings.WEBHOOK else 'Polling'}\n"
|
||||
f.write(start_entry)
|
||||
|
||||
logger.debug(f"Информация о боте записана в {info_file}", log_type='BOT_INFO')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи информации в файл: {e}", log_type='BOT_INFO')
|
||||
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
async def setup(
|
||||
cls,
|
||||
bots: Bot = bot,
|
||||
perm: bool = None,
|
||||
setup_webhook: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Выполняет полную настройку бота.
|
||||
|
||||
Args:
|
||||
bots: Объект бота для управления
|
||||
perm: Разрешение на изменения (если None, берется из настроек)
|
||||
setup_webhook: Устанавливать ли webhook (по умолчанию True)
|
||||
"""
|
||||
perm = perm if perm is not None else settings.BOT_EDIT
|
||||
|
||||
logger.info("🚀 Процесс запуска бота!", log_type='START')
|
||||
|
||||
# Настройка вебхука (только если разрешено)
|
||||
if setup_webhook:
|
||||
await cls.webhook(bots=bots)
|
||||
|
||||
# Получение информации
|
||||
await cls.info(bots=bots)
|
||||
|
||||
# Обновление профиля (если разрешено)
|
||||
if perm:
|
||||
logger.info("Начало настройки профиля бота...", log_type='BOT_SETUP')
|
||||
|
||||
results = {
|
||||
'name': await cls.set_name(bots=bots),
|
||||
'description': await cls.set_description(bots=bots),
|
||||
'short_description': await cls.set_short_description(bots=bots),
|
||||
'admin_rights': await cls.set_administrator_rights(bots=bots)
|
||||
}
|
||||
|
||||
changed_count = sum(results.values())
|
||||
logger.info(
|
||||
f"Настройка завершена. Изменено параметров: {changed_count}/4",
|
||||
log_type='BOT_SETUP'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Изменение настроек бота отключено (BOT_EDIT=False)",
|
||||
log_type='BOT_SETUP'
|
||||
)
|
||||
|
||||
# Вывод красивой информации
|
||||
cls.print()
|
||||
|
||||
259
bot/core/webhook.py
Normal file
259
bot/core/webhook.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Управление вебхуком бота через класс-менеджер
|
||||
"""
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import WebhookInfo
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('WebhookManager',)
|
||||
|
||||
|
||||
class WebhookManager:
|
||||
"""
|
||||
Менеджер для управления webhook режимом.
|
||||
|
||||
Инкапсулирует всю логику работы с webhook:
|
||||
- Создание aiohttp приложения
|
||||
- Регистрация handlers
|
||||
- Установка/удаление webhook
|
||||
- Запуск webhook сервера
|
||||
|
||||
Attributes:
|
||||
bot: Экземпляр бота
|
||||
dp: Диспетчер
|
||||
app: aiohttp приложение
|
||||
secret_token: Секретный токен для webhook
|
||||
"""
|
||||
|
||||
def __init__(self, bot: Bot, dp: Dispatcher):
|
||||
"""
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
dp: Диспетчер
|
||||
"""
|
||||
self.bot = bot
|
||||
self.dp = dp
|
||||
self.app = web.Application()
|
||||
self._configured = False
|
||||
|
||||
# Генерируем или используем существующий токен
|
||||
self.secret_token = self._get_or_generate_token()
|
||||
|
||||
def _get_or_generate_token(self) -> str:
|
||||
"""
|
||||
Получает токен из настроек или генерирует новый.
|
||||
|
||||
Returns:
|
||||
str: Секретный токен
|
||||
"""
|
||||
if hasattr(settings, 'SECRET_TOKEN') and settings.SECRET_TOKEN:
|
||||
logger.debug("Используется SECRET_TOKEN из настроек", log_type='WEBHOOK')
|
||||
return settings.SECRET_TOKEN
|
||||
|
||||
# Генерируем случайный токен (32 символа)
|
||||
token = secrets.token_urlsafe(32)
|
||||
logger.info(
|
||||
f"🔐 Сгенерирован новый SECRET_TOKEN: {token[:8]}...",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
return token
|
||||
|
||||
async def get_info(self) -> WebhookInfo:
|
||||
"""
|
||||
Получает информацию о текущем вебхуке.
|
||||
|
||||
Returns:
|
||||
WebhookInfo: Информация о вебхуке
|
||||
"""
|
||||
try:
|
||||
info = await self.bot.get_webhook_info()
|
||||
logger.debug(
|
||||
f"Webhook URL: {info.url or 'не установлен'}",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения информации о вебхуке: {e}",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
raise
|
||||
|
||||
async def delete(self, drop_pending_updates: bool = True) -> bool:
|
||||
"""
|
||||
Удаляет текущий вебхук.
|
||||
|
||||
Args:
|
||||
drop_pending_updates: Удалить накопленные обновления
|
||||
|
||||
Returns:
|
||||
bool: True если удаление успешно
|
||||
"""
|
||||
try:
|
||||
result = await self.bot.delete_webhook(
|
||||
drop_pending_updates=drop_pending_updates
|
||||
)
|
||||
|
||||
if result:
|
||||
logger.success("✓ Вебхук успешно удален", log_type='WEBHOOK')
|
||||
else:
|
||||
logger.debug("Вебхук не был установлен", log_type='WEBHOOK')
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления вебхука: {e}", log_type='WEBHOOK')
|
||||
return False
|
||||
|
||||
async def setup(
|
||||
self,
|
||||
webhook_url: Optional[str] = None,
|
||||
secret_token: Optional[str] = None,
|
||||
drop_pending_updates: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Устанавливает вебхук для бота.
|
||||
|
||||
Args:
|
||||
webhook_url: URL вебхука (если None, берется из settings)
|
||||
secret_token: Секретный токен (если None, используется self.secret_token)
|
||||
drop_pending_updates: Удалить накопленные обновления
|
||||
|
||||
Returns:
|
||||
bool: True если установка успешна
|
||||
"""
|
||||
url = webhook_url or settings.WEBHOOK_URL
|
||||
token = secret_token or self.secret_token
|
||||
|
||||
if not url:
|
||||
logger.error("WEBHOOK_URL не установлен", log_type='WEBHOOK')
|
||||
return False
|
||||
|
||||
try:
|
||||
# Проверяем текущий webhook
|
||||
current_info = await self.bot.get_webhook_info()
|
||||
|
||||
# Если уже установлен правильный URL, не трогаем
|
||||
if current_info.url == url:
|
||||
logger.info(
|
||||
f"✓ Webhook уже установлен на {url}",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
return True
|
||||
|
||||
# Удаляем старый webhook если есть
|
||||
if current_info.url:
|
||||
logger.debug(f"Удаление старого webhook: {current_info.url}", log_type='WEBHOOK')
|
||||
await self.delete(drop_pending_updates=drop_pending_updates)
|
||||
|
||||
# Небольшая задержка
|
||||
import asyncio
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Устанавливаем новый
|
||||
result = await self.bot.set_webhook(
|
||||
url=url,
|
||||
secret_token=token,
|
||||
drop_pending_updates=drop_pending_updates
|
||||
)
|
||||
|
||||
if result:
|
||||
logger.success(f"✓ Вебхук установлен: {url}", log_type='WEBHOOK')
|
||||
else:
|
||||
logger.error("❌ Не удалось установить вебхук", log_type='WEBHOOK')
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка установки вебхука: {e}", log_type='WEBHOOK')
|
||||
return False
|
||||
|
||||
def configure(
|
||||
self,
|
||||
webhook_path: Optional[str] = None,
|
||||
secret_token: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Конфигурирует webhook handler для aiohttp app.
|
||||
|
||||
Args:
|
||||
webhook_path: Путь для webhook (если None, извлекается из WEBHOOK_URL)
|
||||
secret_token: Секретный токен (если None, используется self.secret_token)
|
||||
"""
|
||||
if self._configured:
|
||||
logger.warning("Webhook уже сконфигурирован", log_type='WEBHOOK')
|
||||
return
|
||||
|
||||
# Определяем путь из WEBHOOK_URL
|
||||
if webhook_path:
|
||||
path = webhook_path
|
||||
elif settings.WEBHOOK_URL:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(settings.WEBHOOK_URL)
|
||||
path = parsed.path if parsed.path else "/webhook"
|
||||
else:
|
||||
path = "/webhook"
|
||||
|
||||
# Используем токен
|
||||
token = secret_token or self.secret_token
|
||||
|
||||
# Создаём webhook handler
|
||||
webhook_handler = SimpleRequestHandler(
|
||||
dispatcher=self.dp,
|
||||
bot=self.bot,
|
||||
secret_token=token
|
||||
)
|
||||
|
||||
# Регистрируем в aiohttp app
|
||||
webhook_handler.register(self.app, path=path)
|
||||
setup_application(self.app, self.dp, bot=self.bot)
|
||||
|
||||
self._configured = True
|
||||
logger.success(
|
||||
f"✓ Webhook handler настроен на путь: {path}",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
host: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
access_log: Optional[bool] = None
|
||||
) -> None:
|
||||
"""
|
||||
Запускает webhook сервер (блокирующий вызов).
|
||||
|
||||
Args:
|
||||
host: Хост сервера (если None, берется из settings)
|
||||
port: Порт сервера (если None, берется из settings)
|
||||
access_log: Логировать запросы (если None, берется из settings)
|
||||
"""
|
||||
if not self._configured:
|
||||
logger.error(
|
||||
"Webhook не сконфигурирован! Вызовите configure() перед run()",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
return
|
||||
|
||||
host = host or settings.WEBAPP_HOST
|
||||
port = port or settings.WEBAPP_PORT
|
||||
access_log_enabled = access_log if access_log is not None else settings.ACCES_LOG
|
||||
|
||||
logger.info(
|
||||
f"🌐 Запуск webhook сервера: {host}:{port}",
|
||||
log_type='WEBHOOK'
|
||||
)
|
||||
|
||||
web.run_app(
|
||||
self.app,
|
||||
host=host,
|
||||
port=port,
|
||||
access_log=logger if access_log_enabled else None
|
||||
)
|
||||
11
bot/filters/__init__.py
Normal file
11
bot/filters/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Модуль фильтров для aiogram
|
||||
"""
|
||||
from .subscription import *
|
||||
from .admin import *
|
||||
from .spam import *
|
||||
from .modes import *
|
||||
from .chat_type import *
|
||||
from .msg_content import *
|
||||
from .chat_rights import *
|
||||
from .callback import *
|
||||
109
bot/filters/admin.py
Normal file
109
bot/filters/admin.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Фильтры для проверки прав администратора
|
||||
"""
|
||||
from typing import Union
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
from configs import settings
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('IsSuperAdmin', 'IsAdmin', 'IsOwner')
|
||||
|
||||
|
||||
class IsSuperAdmin(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь суперадминистратором (из .env).
|
||||
|
||||
Суперадмины имеют полный доступ ко всем командам бота.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("addadmin"), IsSuperAdmin())
|
||||
async def add_admin_command(message: Message):
|
||||
await message.answer("Добавление админа...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
user_id = event.from_user.id
|
||||
is_super_admin = user_id in settings.OWNER_ID
|
||||
|
||||
if not is_super_admin:
|
||||
logger.warning(
|
||||
f"Попытка доступа к команде суперадмина от user_id={user_id}",
|
||||
log_type='SECURITY',
|
||||
message=event if isinstance(event, Message) else None
|
||||
)
|
||||
|
||||
return is_super_admin
|
||||
|
||||
|
||||
class IsAdmin(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором (суперадмин или доп. админ).
|
||||
|
||||
Администраторы могут управлять банвордами, но не могут добавлять других админов.
|
||||
Список дополнительных админов загружается из БД через BanWordsManager.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("addword"), IsAdmin())
|
||||
async def add_word_command(message: Message):
|
||||
await message.answer("Добавление банворда...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
user_id = event.from_user.id
|
||||
|
||||
# Проверка суперадмина
|
||||
if user_id in settings.OWNER_ID:
|
||||
return True
|
||||
|
||||
# Проверка доп. админа из БД (через кэш)
|
||||
manager = get_manager()
|
||||
is_db_admin = manager.is_admin_cached(user_id)
|
||||
|
||||
if not is_db_admin:
|
||||
logger.warning(
|
||||
f"Попытка доступа к админ-команде от user_id={user_id}",
|
||||
log_type='SECURITY',
|
||||
message=event if isinstance(event, Message) else None
|
||||
)
|
||||
|
||||
return is_db_admin
|
||||
|
||||
|
||||
class IsOwner(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь первым владельцем бота (OWNER_ID[0]).
|
||||
|
||||
Используется для критических операций (например, полная очистка данных).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("reset_all"), IsOwner())
|
||||
async def reset_command(message: Message):
|
||||
await message.answer("⚠️ Сброс всех данных...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
user_id = event.from_user.id
|
||||
|
||||
# Берём первого суперадмина как владельца
|
||||
owner_id = settings.OWNER_ID[0] if settings.OWNER_ID else None
|
||||
|
||||
is_owner = user_id == owner_id
|
||||
|
||||
if not is_owner:
|
||||
logger.warning(
|
||||
f"Попытка доступа к команде владельца от user_id={user_id}",
|
||||
log_type='SECURITY',
|
||||
message=event if isinstance(event, Message) else None
|
||||
)
|
||||
|
||||
return is_owner
|
||||
253
bot/filters/callback.py
Normal file
253
bot/filters/callback.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Фильтры для обработки callback-запросов
|
||||
"""
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'CallbackStartsWith',
|
||||
'CallbackEndsWith',
|
||||
'CallbackContains',
|
||||
'CallbackMatches',
|
||||
'CallbackIn'
|
||||
)
|
||||
|
||||
|
||||
class CallbackStartsWith(BaseFilter):
|
||||
"""
|
||||
Проверяет, начинается ли callback_data с указанного префикса.
|
||||
|
||||
Attributes:
|
||||
prefix: Префикс для проверки (строка или список строк)
|
||||
ignore_case: Игнорировать регистр
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Один префикс
|
||||
@router.callback_query(CallbackStartsWith("menu:"))
|
||||
async def menu_handler(callback: CallbackQuery):
|
||||
await callback.answer("Меню")
|
||||
|
||||
# Несколько префиксов
|
||||
@router.callback_query(CallbackStartsWith(["admin:", "mod:"]))
|
||||
async def admin_handler(callback: CallbackQuery):
|
||||
await callback.answer("Админ панель")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: Union[str, list[str]], ignore_case: bool = True):
|
||||
"""
|
||||
Args:
|
||||
prefix: Префикс или список префиксов
|
||||
ignore_case: Игнорировать регистр букв
|
||||
"""
|
||||
self.prefixes = [prefix] if isinstance(prefix, str) else prefix
|
||||
self.ignore_case = ignore_case
|
||||
|
||||
if self.ignore_case:
|
||||
self.prefixes = [p.lower() for p in self.prefixes]
|
||||
|
||||
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
|
||||
if not callback.data:
|
||||
return False
|
||||
|
||||
data = callback.data.lower() if self.ignore_case else callback.data
|
||||
|
||||
for prefix in self.prefixes:
|
||||
if data.startswith(prefix):
|
||||
# Извлекаем данные после префикса
|
||||
value = callback.data[len(prefix):]
|
||||
|
||||
logger.debug(
|
||||
f"Callback с префиксом '{prefix}': {callback.data}",
|
||||
log_type='CALLBACK'
|
||||
)
|
||||
|
||||
return {
|
||||
'matched': True,
|
||||
'prefix': prefix,
|
||||
'value': value,
|
||||
'full_data': callback.data
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CallbackEndsWith(BaseFilter):
|
||||
"""
|
||||
Проверяет, заканчивается ли callback_data на указанный суффикс.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.callback_query(CallbackEndsWith(":confirm"))
|
||||
async def confirm_handler(callback: CallbackQuery, matched: dict):
|
||||
action = matched['value']
|
||||
await callback.answer(f"Подтверждение: {action}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, suffix: Union[str, list[str]], ignore_case: bool = True):
|
||||
"""
|
||||
Args:
|
||||
suffix: Суффикс или список суффиксов
|
||||
ignore_case: Игнорировать регистр букв
|
||||
"""
|
||||
self.suffixes = [suffix] if isinstance(suffix, str) else suffix
|
||||
self.ignore_case = ignore_case
|
||||
|
||||
if self.ignore_case:
|
||||
self.suffixes = [s.lower() for s in self.suffixes]
|
||||
|
||||
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
|
||||
if not callback.data:
|
||||
return False
|
||||
|
||||
data = callback.data.lower() if self.ignore_case else callback.data
|
||||
|
||||
for suffix in self.suffixes:
|
||||
if data.endswith(suffix):
|
||||
# Извлекаем данные до суффикса
|
||||
value = callback.data[:-len(suffix)]
|
||||
|
||||
return {
|
||||
'matched': True,
|
||||
'suffix': suffix,
|
||||
'value': value,
|
||||
'full_data': callback.data
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CallbackContains(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли callback_data указанную подстроку.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.callback_query(CallbackContains("delete"))
|
||||
async def delete_handler(callback: CallbackQuery):
|
||||
await callback.answer("Удаление...")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, substring: Union[str, list[str]], ignore_case: bool = True):
|
||||
"""
|
||||
Args:
|
||||
substring: Подстрока или список подстрок
|
||||
ignore_case: Игнорировать регистр букв
|
||||
"""
|
||||
self.substrings = [substring] if isinstance(substring, str) else substring
|
||||
self.ignore_case = ignore_case
|
||||
|
||||
if self.ignore_case:
|
||||
self.substrings = [s.lower() for s in self.substrings]
|
||||
|
||||
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
|
||||
if not callback.data:
|
||||
return False
|
||||
|
||||
data = callback.data.lower() if self.ignore_case else callback.data
|
||||
|
||||
for substring in self.substrings:
|
||||
if substring in data:
|
||||
return {
|
||||
'matched': True,
|
||||
'substring': substring,
|
||||
'full_data': callback.data
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CallbackMatches(BaseFilter):
|
||||
"""
|
||||
Проверяет callback_data по regex паттерну.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Паттерн: user_123, user_456 и т.д.
|
||||
@router.callback_query(CallbackMatches(r"^user_(\d+)$"))
|
||||
async def user_handler(callback: CallbackQuery, matched: dict):
|
||||
user_id = matched['groups']
|
||||
await callback.answer(f"Пользователь {user_id}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, pattern: Union[str, re.Pattern], flags: int = 0):
|
||||
"""
|
||||
Args:
|
||||
pattern: Regex паттерн (строка или скомпилированный Pattern)
|
||||
flags: Флаги для regex (например, re.IGNORECASE)
|
||||
"""
|
||||
if isinstance(pattern, str):
|
||||
self.pattern = re.compile(pattern, flags)
|
||||
else:
|
||||
self.pattern = pattern
|
||||
|
||||
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
|
||||
if not callback.data:
|
||||
return False
|
||||
|
||||
match = self.pattern.match(callback.data)
|
||||
|
||||
if match:
|
||||
logger.debug(
|
||||
f"Callback соответствует паттерну {self.pattern.pattern}: {callback.data}",
|
||||
log_type='CALLBACK'
|
||||
)
|
||||
|
||||
return {
|
||||
'matched': True,
|
||||
'pattern': self.pattern.pattern,
|
||||
'groups': match.groups(),
|
||||
'groupdict': match.groupdict(),
|
||||
'full_data': callback.data
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CallbackIn(BaseFilter):
|
||||
"""
|
||||
Проверяет, находится ли callback_data в списке разрешенных значений.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.callback_query(CallbackIn(["yes", "no", "cancel"]))
|
||||
async def choice_handler(callback: CallbackQuery):
|
||||
choice = callback.data
|
||||
await callback.answer(f"Выбрано: {choice}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, values: list[str], ignore_case: bool = True):
|
||||
"""
|
||||
Args:
|
||||
values: Список разрешенных значений
|
||||
ignore_case: Игнорировать регистр букв
|
||||
"""
|
||||
self.values = values
|
||||
self.ignore_case = ignore_case
|
||||
|
||||
if self.ignore_case:
|
||||
self.values = [v.lower() for v in values]
|
||||
|
||||
async def __call__(self, callback: CallbackQuery) -> Union[bool, dict]:
|
||||
if not callback.data:
|
||||
return False
|
||||
|
||||
data = callback.data.lower() if self.ignore_case else callback.data
|
||||
|
||||
if data in self.values:
|
||||
return {
|
||||
'matched': True,
|
||||
'value': callback.data
|
||||
}
|
||||
|
||||
return False
|
||||
324
bot/filters/chat_rights.py
Normal file
324
bot/filters/chat_rights.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Фильтры для проверки прав пользователей в чатах
|
||||
"""
|
||||
from typing import Any, Union
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'IsBotOwner',
|
||||
'IsChatCreator',
|
||||
'IsChatAdmin',
|
||||
'IsModerator',
|
||||
'CanDeleteMessages',
|
||||
'CanRestrictMembers',
|
||||
'CanPinMessages'
|
||||
)
|
||||
|
||||
|
||||
class IsBotOwner(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь владельцем бота (из .env).
|
||||
|
||||
Attributes:
|
||||
send_error_message: Отправлять ли сообщение об ошибке доступа
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Без сообщения об ошибке
|
||||
@router.message(Command("reset"), IsOwner())
|
||||
async def reset_command(message: Message):
|
||||
await message.answer("🔄 Сброс данных...")
|
||||
|
||||
# С сообщением об ошибке
|
||||
@router.message(Command("secret"), IsOwner(send_error_message=True))
|
||||
async def secret_command(message: Message):
|
||||
await message.answer("🔐 Секретная команда выполнена")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, send_error_message: bool = False) -> None:
|
||||
"""
|
||||
Args:
|
||||
send_error_message: Если True, отправляет сообщение при отказе в доступе
|
||||
"""
|
||||
self.send_error_message = send_error_message
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
event: Union[Message, CallbackQuery],
|
||||
bot: Bot
|
||||
) -> Union[bool, dict[str, Any]]:
|
||||
"""
|
||||
Проверка владельца бота.
|
||||
|
||||
Returns:
|
||||
bool или dict: True/dict если владелец, False иначе
|
||||
"""
|
||||
if not event.from_user:
|
||||
return False
|
||||
|
||||
user_id = event.from_user.id
|
||||
is_owner = user_id in settings.OWNER_ID
|
||||
|
||||
if not is_owner:
|
||||
logger.warning(
|
||||
f"Попытка доступа к команде владельца от user_id={user_id}",
|
||||
log_type='SECURITY',
|
||||
message=event if isinstance(event, Message) else None
|
||||
)
|
||||
|
||||
if self.send_error_message:
|
||||
error_text = "⛔ Эта команда доступна только владельцу бота!"
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(error_text)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(error_text, show_alert=True)
|
||||
|
||||
return False
|
||||
|
||||
# Возвращаем информацию для handler
|
||||
return {
|
||||
'is_owner': True,
|
||||
'user_id': user_id,
|
||||
'owner_ids': settings.OWNER_ID
|
||||
}
|
||||
|
||||
|
||||
class IsChatCreator(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь создателем чата.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("transfer"), IsChatCreator())
|
||||
async def transfer_ownership(message: Message):
|
||||
await message.answer("👑 Передача владения чатом...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
is_creator = member.status == ChatMemberStatus.CREATOR
|
||||
|
||||
if is_creator:
|
||||
return {
|
||||
'is_creator': True,
|
||||
'user_id': message.from_user.id,
|
||||
'chat_id': message.chat.id
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки создателя чата: {e}",
|
||||
log_type='CHAT_RIGHTS',
|
||||
message=message
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class IsChatAdmin(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором чата (или создателем).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("ban"), IsChatAdmin())
|
||||
async def ban_user(message: Message):
|
||||
await message.answer("🔨 Бан пользователя...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
is_admin = member.status in (
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
)
|
||||
|
||||
if is_admin:
|
||||
return {
|
||||
'is_admin': True,
|
||||
'status': member.status.value,
|
||||
'user_id': message.from_user.id,
|
||||
'chat_id': message.chat.id
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки администратора чата: {e}",
|
||||
log_type='CHAT_RIGHTS',
|
||||
message=message
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class IsModerator(BaseFilter):
|
||||
"""
|
||||
Проверяет, имеет ли администратор модераторские права:
|
||||
- Удаление сообщений
|
||||
- Ограничение пользователей
|
||||
- Закрепление сообщений
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("warn"), IsModerator())
|
||||
async def warn_user(message: Message):
|
||||
await message.answer("⚠️ Предупреждение пользователю...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> Union[bool, dict]:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
# Создатель всегда модератор
|
||||
if member.status == ChatMemberStatus.CREATOR:
|
||||
return {
|
||||
'is_moderator': True,
|
||||
'status': 'creator',
|
||||
'user_id': message.from_user.id
|
||||
}
|
||||
|
||||
# Проверка прав администратора
|
||||
if member.status != ChatMemberStatus.ADMINISTRATOR:
|
||||
return False
|
||||
|
||||
# Проверка модераторских прав
|
||||
required_rights = [
|
||||
getattr(member, 'can_delete_messages', False),
|
||||
getattr(member, 'can_restrict_members', False),
|
||||
getattr(member, 'can_pin_messages', False),
|
||||
]
|
||||
|
||||
has_all_rights = all(required_rights)
|
||||
|
||||
if has_all_rights:
|
||||
return {
|
||||
'is_moderator': True,
|
||||
'status': 'administrator',
|
||||
'can_delete': required_rights[0],
|
||||
'can_restrict': required_rights[1],
|
||||
'can_pin': required_rights[2],
|
||||
'user_id': message.from_user.id
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки модератора: {e}",
|
||||
log_type='CHAT_RIGHTS',
|
||||
message=message
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class CanDeleteMessages(BaseFilter):
|
||||
"""
|
||||
Проверяет право на удаление сообщений.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("clear"), CanDeleteMessages())
|
||||
async def clear_messages(message: Message):
|
||||
await message.answer("🗑️ Очистка сообщений...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> bool:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
if member.status == ChatMemberStatus.CREATOR:
|
||||
return True
|
||||
|
||||
return getattr(member, 'can_delete_messages', False)
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
class CanRestrictMembers(BaseFilter):
|
||||
"""
|
||||
Проверяет право на ограничение пользователей (бан, мут).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("mute"), CanRestrictMembers())
|
||||
async def mute_user(message: Message):
|
||||
await message.answer("🔇 Мут пользователя...")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> bool:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
if member.status == ChatMemberStatus.CREATOR:
|
||||
return True
|
||||
|
||||
return getattr(member, 'can_restrict_members', False)
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
class CanPinMessages(BaseFilter):
|
||||
"""
|
||||
Проверяет право на закрепление сообщений.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("pin"), CanPinMessages())
|
||||
async def pin_message(message: Message):
|
||||
if message.reply_to_message:
|
||||
await message.reply_to_message.pin()
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message, bot: Bot) -> bool:
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
if member.status == ChatMemberStatus.CREATOR:
|
||||
return True
|
||||
|
||||
return getattr(member, 'can_pin_messages', False)
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
105
bot/filters/chat_type.py
Normal file
105
bot/filters/chat_type.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Фильтры для проверки типов чатов
|
||||
"""
|
||||
from typing import Union
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.enums import ChatType
|
||||
|
||||
__all__ = ('IsPrivateChat', 'IsGroupChat', 'IsSuperGroupChat', 'IsChannelChat', 'IsAnyGroup')
|
||||
|
||||
|
||||
class IsPrivateChat(BaseFilter):
|
||||
"""
|
||||
Проверяет, что сообщение из личного чата (приватный диалог с ботом).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("start"), IsPrivateChat())
|
||||
async def start_private(message: Message):
|
||||
await message.answer("Привет в личке!")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
if isinstance(event, CallbackQuery):
|
||||
event = event.message
|
||||
|
||||
return event.chat.type == ChatType.PRIVATE
|
||||
|
||||
|
||||
class IsGroupChat(BaseFilter):
|
||||
"""
|
||||
Проверяет, что сообщение из обычной группы (не супергруппы).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(IsGroupChat())
|
||||
async def group_message(message: Message):
|
||||
await message.answer("Это обычная группа")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
if isinstance(event, CallbackQuery):
|
||||
event = event.message
|
||||
|
||||
return event.chat.type == ChatType.GROUP
|
||||
|
||||
|
||||
class IsSuperGroupChat(BaseFilter):
|
||||
"""
|
||||
Проверяет, что сообщение из супергруппы.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(IsSuperGroupChat())
|
||||
async def supergroup_message(message: Message):
|
||||
await message.answer("Это супергруппа")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
if isinstance(event, CallbackQuery):
|
||||
event = event.message
|
||||
|
||||
return event.chat.type == ChatType.SUPERGROUP
|
||||
|
||||
|
||||
class IsChannelChat(BaseFilter):
|
||||
"""
|
||||
Проверяет, что сообщение из канала.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(IsChannelChat())
|
||||
async def channel_message(message: Message):
|
||||
await message.answer("Это канал")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
if isinstance(event, CallbackQuery):
|
||||
event = event.message
|
||||
|
||||
return event.chat.type == ChatType.CHANNEL
|
||||
|
||||
|
||||
class IsAnyGroup(BaseFilter):
|
||||
"""
|
||||
Проверяет, что сообщение из любой группы (обычная или супергруппа).
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("admin"), IsAnyGroup())
|
||||
async def admin_command(message: Message):
|
||||
await message.answer("Команда доступна только в группах")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, event: Union[Message, CallbackQuery]) -> bool:
|
||||
if isinstance(event, CallbackQuery):
|
||||
event = event.message
|
||||
|
||||
return event.chat.type in (ChatType.GROUP, ChatType.SUPERGROUP)
|
||||
184
bot/filters/modes.py
Normal file
184
bot/filters/modes.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Фильтры для проверки активных режимов бота (silence, conflict)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('IsSilenceActive', 'IsConflictModeActive')
|
||||
|
||||
|
||||
class IsSilenceActive(BaseFilter):
|
||||
"""
|
||||
Проверяет, активен ли режим тишины (silence mode).
|
||||
|
||||
В режиме тишины удаляются ВСЕ сообщения (кроме админов).
|
||||
|
||||
Attributes:
|
||||
silence_until: Время до которого активен режим (None = неактивен)
|
||||
|
||||
Example:
|
||||
```python
|
||||
# В handler-файле
|
||||
silence_filter = IsSilenceActive()
|
||||
|
||||
@router.message(silence_filter)
|
||||
async def silence_mode_active(message: Message):
|
||||
# Удаляем все сообщения в режиме тишины
|
||||
await message.delete()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, silence_until: Optional[datetime] = None):
|
||||
"""
|
||||
Args:
|
||||
silence_until: Datetime до которого активен режим
|
||||
"""
|
||||
self.silence_until = silence_until
|
||||
|
||||
def update_silence_until(self, new_datetime: Optional[datetime]) -> None:
|
||||
"""
|
||||
Обновляет время окончания режима тишины.
|
||||
|
||||
Args:
|
||||
new_datetime: Новое время окончания или None для отключения
|
||||
"""
|
||||
self.silence_until = new_datetime
|
||||
|
||||
if new_datetime:
|
||||
logger.info(
|
||||
f"Режим тишины активирован до {new_datetime.strftime('%H:%M:%S')}",
|
||||
log_type='SILENCE'
|
||||
)
|
||||
else:
|
||||
logger.info("Режим тишины отключен", log_type='SILENCE')
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""
|
||||
Проверяет, активен ли режим сейчас.
|
||||
|
||||
Returns:
|
||||
bool: True если режим активен
|
||||
"""
|
||||
if self.silence_until is None:
|
||||
return False
|
||||
|
||||
# Проверка истечения времени
|
||||
if datetime.now() >= self.silence_until:
|
||||
logger.info("Режим тишины автоматически завершен", log_type='SILENCE')
|
||||
self.silence_until = None
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def __call__(self, event: Message) -> Optional[dict]:
|
||||
"""
|
||||
Проверка активности режима тишины.
|
||||
|
||||
Returns:
|
||||
dict или None: Информация о режиме если активен, иначе None
|
||||
"""
|
||||
if self.is_active():
|
||||
remaining = (self.silence_until - datetime.now()).total_seconds()
|
||||
logger.debug(
|
||||
f"Режим тишины активен (осталось {remaining:.0f}с)",
|
||||
log_type='SILENCE',
|
||||
message=event
|
||||
)
|
||||
return {
|
||||
'is_active': True,
|
||||
'until': self.silence_until,
|
||||
'remaining_seconds': remaining
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class IsConflictModeActive(BaseFilter):
|
||||
"""
|
||||
Проверяет, активен ли режим антиконфликта (conflict mode).
|
||||
|
||||
В режиме антиконфликта удаляются сообщения с конфликтными словами.
|
||||
|
||||
Attributes:
|
||||
conflict_until: Время до которого активен режим (None = неактивен)
|
||||
|
||||
Example:
|
||||
```python
|
||||
conflict_filter = IsConflictModeActive()
|
||||
|
||||
@router.message(conflict_filter)
|
||||
async def conflict_mode_active(message: Message):
|
||||
# Проверяем на конфликтные слова и удаляем
|
||||
if has_conflict_words(message.text):
|
||||
await message.delete()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, conflict_until: Optional[datetime] = None):
|
||||
"""
|
||||
Args:
|
||||
conflict_until: Datetime до которого активен режим
|
||||
"""
|
||||
self.conflict_until = conflict_until
|
||||
|
||||
def update_conflict_until(self, new_datetime: Optional[datetime]) -> None:
|
||||
"""
|
||||
Обновляет время окончания режима антиконфликта.
|
||||
|
||||
Args:
|
||||
new_datetime: Новое время окончания или None для отключения
|
||||
"""
|
||||
self.conflict_until = new_datetime
|
||||
|
||||
if new_datetime:
|
||||
logger.info(
|
||||
f"Режим антиконфликта активирован до {new_datetime.strftime('%H:%M:%S')}",
|
||||
log_type='CONFLICT'
|
||||
)
|
||||
else:
|
||||
logger.info("Режим антиконфликта отключен", log_type='CONFLICT')
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""
|
||||
Проверяет, активен ли режим сейчас.
|
||||
|
||||
Returns:
|
||||
bool: True если режим активен
|
||||
"""
|
||||
if self.conflict_until is None:
|
||||
return False
|
||||
|
||||
# Проверка истечения времени
|
||||
if datetime.now() >= self.conflict_until:
|
||||
logger.info("Режим антиконфликта автоматически завершен", log_type='CONFLICT')
|
||||
self.conflict_until = None
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def __call__(self, event: Message) -> Optional[dict]:
|
||||
"""
|
||||
Проверка активности режима антиконфликта.
|
||||
|
||||
Returns:
|
||||
dict или None: Информация о режиме если активен, иначе None
|
||||
"""
|
||||
if self.is_active():
|
||||
remaining = (self.conflict_until - datetime.now()).total_seconds()
|
||||
logger.debug(
|
||||
f"Режим антиконфликта активен (осталось {remaining:.0f}с)",
|
||||
log_type='CONFLICT',
|
||||
message=event
|
||||
)
|
||||
return {
|
||||
'is_active': True,
|
||||
'until': self.conflict_until,
|
||||
'remaining_seconds': remaining
|
||||
}
|
||||
|
||||
return None
|
||||
395
bot/filters/msg_content.py
Normal file
395
bot/filters/msg_content.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Фильтры для проверки содержимого сообщений
|
||||
"""
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message, ContentType
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'IsReply',
|
||||
'IsForwarded',
|
||||
'HasMedia',
|
||||
'ContainsURL',
|
||||
'HasText',
|
||||
'HasCaption',
|
||||
'HasEntities',
|
||||
'MediaType'
|
||||
)
|
||||
|
||||
|
||||
class IsReply(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли сообщение ответом на другое сообщение.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(IsReply())
|
||||
async def handle_reply(message: Message):
|
||||
original = message.reply_to_message
|
||||
await message.answer(f"Это ответ на: {original.text}")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
is_reply = message.reply_to_message is not None
|
||||
|
||||
if is_reply:
|
||||
return {
|
||||
'is_reply': True,
|
||||
'reply_to_message': message.reply_to_message,
|
||||
'reply_to_user_id': message.reply_to_message.from_user.id if message.reply_to_message.from_user else None
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsForwarded(BaseFilter):
|
||||
"""
|
||||
Проверяет, является ли сообщение пересланным.
|
||||
|
||||
Поддерживает:
|
||||
- Пересылку от пользователей (forward_from)
|
||||
- Пересылку из каналов/групп (forward_from_chat)
|
||||
- Скрытую пересылку (forward_sender_name)
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(IsForwarded())
|
||||
async def handle_forwarded(message: Message, forward_info: dict):
|
||||
await message.answer(f"Переслано из: {forward_info['origin']}")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
# Проверка различных типов пересылки
|
||||
is_forwarded = (
|
||||
message.forward_origin is not None or # Новый API (aiogram 3.x)
|
||||
message.forward_from is not None or
|
||||
message.forward_from_chat is not None or
|
||||
message.forward_sender_name is not None
|
||||
)
|
||||
|
||||
if is_forwarded:
|
||||
origin = "неизвестно"
|
||||
|
||||
if message.forward_from:
|
||||
origin = f"пользователь @{message.forward_from.username or message.forward_from.id}"
|
||||
elif message.forward_from_chat:
|
||||
origin = f"чат {message.forward_from_chat.title or message.forward_from_chat.id}"
|
||||
elif message.forward_sender_name:
|
||||
origin = f"скрытый пользователь ({message.forward_sender_name})"
|
||||
|
||||
logger.debug(
|
||||
f"Обнаружено пересланное сообщение из: {origin}",
|
||||
log_type='FORWARD',
|
||||
message=message
|
||||
)
|
||||
|
||||
return {
|
||||
'is_forwarded': True,
|
||||
'origin': origin,
|
||||
'forward_date': message.forward_date
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class HasMedia(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли сообщение медиа-контент.
|
||||
|
||||
Attributes:
|
||||
media_types: Список типов медиа для проверки (если None, проверяются все)
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Любое медиа
|
||||
@router.message(HasMedia())
|
||||
async def handle_media(message: Message):
|
||||
await message.answer("Получено медиа!")
|
||||
|
||||
# Только фото и видео
|
||||
@router.message(HasMedia(['photo', 'video']))
|
||||
async def handle_visual(message: Message):
|
||||
await message.answer("Фото или видео!")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, media_types: Optional[list[str]] = None):
|
||||
"""
|
||||
Args:
|
||||
media_types: Список типов медиа ('photo', 'video', 'document', и т.д.)
|
||||
Если None, проверяются все типы
|
||||
"""
|
||||
self.media_types = media_types
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
# Все возможные типы медиа
|
||||
media_checks = {
|
||||
'photo': message.photo,
|
||||
'video': message.video,
|
||||
'document': message.document,
|
||||
'audio': message.audio,
|
||||
'voice': message.voice,
|
||||
'video_note': message.video_note,
|
||||
'sticker': message.sticker,
|
||||
'animation': message.animation,
|
||||
}
|
||||
|
||||
# Если указаны конкретные типы, проверяем только их
|
||||
if self.media_types:
|
||||
has_media = any(
|
||||
media_checks[media_type]
|
||||
for media_type in self.media_types
|
||||
if media_type in media_checks
|
||||
)
|
||||
detected_type = next(
|
||||
(media_type for media_type in self.media_types if media_checks.get(media_type)),
|
||||
None
|
||||
)
|
||||
else:
|
||||
# Проверяем все типы
|
||||
has_media = any(media_checks.values())
|
||||
detected_type = next(
|
||||
(media_type for media_type, value in media_checks.items() if value),
|
||||
None
|
||||
)
|
||||
|
||||
if has_media:
|
||||
return {
|
||||
'has_media': True,
|
||||
'media_type': detected_type,
|
||||
'content': media_checks[detected_type]
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class ContainsURL(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли сообщение ссылки.
|
||||
|
||||
Поддерживает:
|
||||
- HTTP/HTTPS ссылки
|
||||
- Telegram ссылки (t.me, tg://)
|
||||
- Проверку через entities (более точная)
|
||||
|
||||
Attributes:
|
||||
strict: Использовать строгую проверку через entities
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(ContainsURL())
|
||||
async def handle_url(message: Message, url_info: dict):
|
||||
urls = url_info['urls']
|
||||
await message.answer(f"Обнаружено {len(urls)} ссылок")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, strict: bool = False):
|
||||
"""
|
||||
Args:
|
||||
strict: Если True, проверяет через entities (игнорирует текст в коде/pre)
|
||||
"""
|
||||
self.strict = strict
|
||||
# Паттерн для поиска URL
|
||||
self.url_pattern = re.compile(
|
||||
r'https?://[^\s]+|' # http(s)://
|
||||
r't\.me/[^\s]+|' # t.me/
|
||||
r'tg://[^\s]+', # tg://
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
if not message.text and not message.caption:
|
||||
return False
|
||||
|
||||
text = message.text or message.caption
|
||||
|
||||
if self.strict and message.entities:
|
||||
# Строгая проверка через entities
|
||||
url_entities = [
|
||||
entity for entity in message.entities
|
||||
if entity.type in ('url', 'text_link')
|
||||
]
|
||||
|
||||
if url_entities:
|
||||
urls = []
|
||||
for entity in url_entities:
|
||||
if entity.type == 'url':
|
||||
url = text[entity.offset:entity.offset + entity.length]
|
||||
urls.append(url)
|
||||
elif entity.type == 'text_link':
|
||||
urls.append(entity.url)
|
||||
|
||||
return {
|
||||
'contains_url': True,
|
||||
'urls': urls,
|
||||
'url_count': len(urls)
|
||||
}
|
||||
else:
|
||||
# Простая проверка через regex
|
||||
urls = self.url_pattern.findall(text)
|
||||
|
||||
if urls:
|
||||
return {
|
||||
'contains_url': True,
|
||||
'urls': urls,
|
||||
'url_count': len(urls)
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class HasText(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли сообщение текст.
|
||||
|
||||
Attributes:
|
||||
min_length: Минимальная длина текста (по умолчанию 1)
|
||||
max_length: Максимальная длина текста (по умолчанию None)
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Любой текст
|
||||
@router.message(HasText())
|
||||
async def handle_text(message: Message):
|
||||
await message.answer("Получен текст!")
|
||||
|
||||
# Текст от 10 до 100 символов
|
||||
@router.message(HasText(min_length=10, max_length=100))
|
||||
async def handle_medium_text(message: Message):
|
||||
await message.answer("Текст подходящей длины!")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, min_length: int = 1, max_length: Optional[int] = None):
|
||||
self.min_length = min_length
|
||||
self.max_length = max_length
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
text_length = len(message.text)
|
||||
|
||||
# Проверка длины
|
||||
if text_length < self.min_length:
|
||||
return False
|
||||
|
||||
if self.max_length and text_length > self.max_length:
|
||||
return False
|
||||
|
||||
return {
|
||||
'has_text': True,
|
||||
'text_length': text_length,
|
||||
'text': message.text
|
||||
}
|
||||
|
||||
|
||||
class HasCaption(BaseFilter):
|
||||
"""
|
||||
Проверяет, есть ли у медиа подпись.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(HasCaption())
|
||||
async def handle_caption(message: Message):
|
||||
await message.answer(f"Подпись: {message.caption}")
|
||||
```
|
||||
"""
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
if message.caption:
|
||||
return {
|
||||
'has_caption': True,
|
||||
'caption': message.caption,
|
||||
'caption_length': len(message.caption)
|
||||
}
|
||||
return False
|
||||
|
||||
|
||||
class HasEntities(BaseFilter):
|
||||
"""
|
||||
Проверяет наличие entities (упоминания, хештеги, команды и т.д.).
|
||||
|
||||
Attributes:
|
||||
entity_types: Список типов entities для проверки
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Любые entities
|
||||
@router.message(HasEntities())
|
||||
async def handle_entities(message: Message):
|
||||
pass
|
||||
|
||||
# Только упоминания и хештеги
|
||||
@router.message(HasEntities(['mention', 'hashtag']))
|
||||
async def handle_mentions(message: Message):
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, entity_types: Optional[list[str]] = None):
|
||||
"""
|
||||
Args:
|
||||
entity_types: Список типов ('mention', 'hashtag', 'bot_command', и т.д.)
|
||||
"""
|
||||
self.entity_types = entity_types
|
||||
|
||||
async def __call__(self, message: Message) -> Union[bool, dict]:
|
||||
if not message.entities:
|
||||
return False
|
||||
|
||||
if self.entity_types:
|
||||
# Фильтруем по типам
|
||||
matching_entities = [
|
||||
entity for entity in message.entities
|
||||
if entity.type in self.entity_types
|
||||
]
|
||||
|
||||
if matching_entities:
|
||||
return {
|
||||
'has_entities': True,
|
||||
'entities': matching_entities,
|
||||
'entity_count': len(matching_entities)
|
||||
}
|
||||
else:
|
||||
# Любые entities
|
||||
return {
|
||||
'has_entities': True,
|
||||
'entities': message.entities,
|
||||
'entity_count': len(message.entities)
|
||||
}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class MediaType(BaseFilter):
|
||||
"""
|
||||
Проверяет точный тип контента сообщения.
|
||||
|
||||
Attributes:
|
||||
content_type: Тип контента из ContentType enum
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(MediaType(ContentType.PHOTO))
|
||||
async def handle_photo(message: Message):
|
||||
await message.answer("Это фото!")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, content_type: Union[ContentType, str]):
|
||||
"""
|
||||
Args:
|
||||
content_type: Тип контента (ContentType enum или строка)
|
||||
"""
|
||||
self.content_type = content_type if isinstance(content_type, str) else content_type.value
|
||||
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
return message.content_type == self.content_type
|
||||
111
bot/filters/spam.py
Normal file
111
bot/filters/spam.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Фильтры для проверки сообщений на спам и банворды
|
||||
"""
|
||||
from typing import Optional, Callable
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('HasSpam', 'IsWhitelisted')
|
||||
|
||||
|
||||
class HasSpam(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли сообщение запрещенные слова (спам).
|
||||
|
||||
Attributes:
|
||||
check_spam_func: Функция проверки спама (передается при инициализации)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from utils.spam_checker import check_spam
|
||||
|
||||
@router.message(HasSpam(check_spam))
|
||||
async def spam_detected(message: Message):
|
||||
await message.delete()
|
||||
await message.answer("⚠️ Сообщение содержит запрещенные слова")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, check_spam_func: Callable[[str], bool]):
|
||||
"""
|
||||
Args:
|
||||
check_spam_func: Функция для проверки спама
|
||||
"""
|
||||
self.check_spam = check_spam_func
|
||||
|
||||
async def __call__(self, message: Message) -> Optional[dict]:
|
||||
"""
|
||||
Проверка сообщения на спам.
|
||||
|
||||
Returns:
|
||||
dict или None: Информация о найденном спаме или None
|
||||
"""
|
||||
if not message.text:
|
||||
return None
|
||||
|
||||
text_lower = message.text.lower()
|
||||
has_spam = self.check_spam(text_lower)
|
||||
|
||||
if has_spam:
|
||||
logger.warning(
|
||||
f"Обнаружен спам в сообщении",
|
||||
log_type='SPAM',
|
||||
message=message
|
||||
)
|
||||
return {'has_spam': True, 'text': text_lower}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class IsWhitelisted(BaseFilter):
|
||||
"""
|
||||
Проверяет, содержит ли сообщение слова из белого списка (исключения).
|
||||
|
||||
Используется для защиты от ложных срабатываний спам-фильтра.
|
||||
|
||||
Attributes:
|
||||
check_whitelist_func: Функция проверки белого списка
|
||||
|
||||
Example:
|
||||
```python
|
||||
from utils.spam_checker import check_whitelist
|
||||
|
||||
@router.message(IsWhitelisted(check_whitelist))
|
||||
async def whitelisted_message(message: Message):
|
||||
# Сообщение содержит исключение, пропускаем проверку спама
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, check_whitelist_func: Callable[[str], bool]):
|
||||
"""
|
||||
Args:
|
||||
check_whitelist_func: Функция для проверки белого списка
|
||||
"""
|
||||
self.check_whitelist = check_whitelist_func
|
||||
|
||||
async def __call__(self, message: Message) -> Optional[bool]:
|
||||
"""
|
||||
Проверка на наличие в белом списке.
|
||||
|
||||
Returns:
|
||||
bool или None: True если в белом списке, None если нет
|
||||
"""
|
||||
if not message.text:
|
||||
return None
|
||||
|
||||
text_lower = message.text.lower()
|
||||
is_whitelisted = self.check_whitelist(text_lower)
|
||||
|
||||
if is_whitelisted:
|
||||
logger.debug(
|
||||
f"Сообщение содержит исключение из белого списка",
|
||||
log_type='WHITELIST',
|
||||
message=message
|
||||
)
|
||||
return True
|
||||
|
||||
return None
|
||||
246
bot/filters/subscription.py
Normal file
246
bot/filters/subscription.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Фильтр проверки подписки пользователя на каналы/группы
|
||||
"""
|
||||
from typing import Union, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('IsSubscribed', 'SubscriptionChecker')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelInfo:
|
||||
"""Информация о канале для проверки подписки"""
|
||||
id: Union[str, int]
|
||||
name: Optional[str] = None
|
||||
invite_link: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionChecker:
|
||||
"""
|
||||
Вспомогательный класс для проверки подписок.
|
||||
Может использоваться отдельно от фильтра.
|
||||
"""
|
||||
|
||||
# Статусы, считающиеся подпиской
|
||||
SUBSCRIBED_STATUSES: set[str] = {
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
}
|
||||
|
||||
# Статусы, означающие отсутствие подписки
|
||||
NOT_SUBSCRIBED_STATUSES: set[str] = {
|
||||
ChatMemberStatus.LEFT,
|
||||
ChatMemberStatus.KICKED,
|
||||
ChatMemberStatus.RESTRICTED # Опционально
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def is_subscribed(
|
||||
cls,
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
channel_id: Union[str, int]
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет подписку одного пользователя на один канал.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
user_id: ID пользователя
|
||||
channel_id: ID или username канала
|
||||
|
||||
Returns:
|
||||
bool: True если подписан
|
||||
"""
|
||||
try:
|
||||
member = await bot.get_chat_member(
|
||||
chat_id=channel_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
is_sub = member.status in cls.SUBSCRIBED_STATUSES
|
||||
|
||||
logger.debug(
|
||||
f"Проверка подписки user={user_id} на канал={channel_id}: {member.status} ({'✅' if is_sub else '❌'})",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
|
||||
return is_sub
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(
|
||||
f"Канал {channel_id} недоступен или неверный ID: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return False
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(
|
||||
f"Бот не имеет доступа к каналу {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Непредвиденная ошибка проверки подписки на {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_all_channels(
|
||||
cls,
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
channels: list[Union[str, int]]
|
||||
) -> dict[Union[str, int], bool]:
|
||||
"""
|
||||
Проверяет подписку на несколько каналов одновременно.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
user_id: ID пользователя
|
||||
channels: Список ID/username каналов
|
||||
|
||||
Returns:
|
||||
dict: Словарь {channel_id: is_subscribed}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for channel in channels:
|
||||
results[channel] = await cls.is_subscribed(bot, user_id, channel)
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
async def get_not_subscribed_channels(
|
||||
cls,
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
channels: list[Union[str, int]]
|
||||
) -> list[Union[str, int]]:
|
||||
"""
|
||||
Возвращает список каналов, на которые пользователь НЕ подписан.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
user_id: ID пользователя
|
||||
channels: Список ID/username каналов
|
||||
|
||||
Returns:
|
||||
list: Список каналов без подписки
|
||||
"""
|
||||
not_subscribed = []
|
||||
|
||||
for channel in channels:
|
||||
if not await cls.is_subscribed(bot, user_id, channel):
|
||||
not_subscribed.append(channel)
|
||||
|
||||
return not_subscribed
|
||||
|
||||
|
||||
class IsSubscribed(BaseFilter):
|
||||
"""
|
||||
Фильтр для проверки подписки пользователя на каналы/группы.
|
||||
|
||||
Поддерживает:
|
||||
- Публичные каналы (username: "@channel_name")
|
||||
- Приватные каналы/группы (ID: -1001234567890)
|
||||
- Проверку всех или хотя бы одного канала
|
||||
- Работу с Message и CallbackQuery
|
||||
|
||||
Attributes:
|
||||
channels: Список ID или username каналов для проверки
|
||||
require_all: Требовать подписку на все каналы (True) или хотя бы один (False)
|
||||
|
||||
Examples:
|
||||
>> # Проверка подписки на один канал
|
||||
>> @router.message(IsSubscribed(["@my_channel"]))
|
||||
>> async def handler(message: Message):
|
||||
... await message.answer("Ты подписан!")
|
||||
|
||||
>> # Проверка на несколько каналов (все обязательны)
|
||||
>> @router.message(IsSubscribed(["@channel1", -1001234567890], require_all=True))
|
||||
>> async def handler(message: Message):
|
||||
... await message.answer("Ты подписан на все каналы!")
|
||||
|
||||
>> # Проверка на несколько каналов (хотя бы один)
|
||||
>> @router.message(IsSubscribed(["@channel1", "@channel2"], require_all=False))
|
||||
>> async def handler(message: Message):
|
||||
... await message.answer("Ты подписан хотя бы на один канал!")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channels: list[Union[str, int]],
|
||||
require_all: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Инициализация фильтра.
|
||||
|
||||
Args:
|
||||
channels: Список ID или username каналов
|
||||
require_all: True = все каналы, False = хотя бы один
|
||||
"""
|
||||
if not channels:
|
||||
raise ValueError("Список каналов не может быть пустым")
|
||||
|
||||
self.channels = channels
|
||||
self.require_all = require_all
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
event: Union[Message, CallbackQuery],
|
||||
bot: Bot
|
||||
) -> Union[bool, dict]:
|
||||
"""
|
||||
Проверка подписки.
|
||||
|
||||
Args:
|
||||
event: Message или CallbackQuery
|
||||
bot: Экземпляр бота
|
||||
|
||||
Returns:
|
||||
bool или dict: True/False для простой проверки,
|
||||
dict с деталями для сложной логики
|
||||
"""
|
||||
user_id = event.from_user.id
|
||||
|
||||
# Проверка всех каналов
|
||||
results = await SubscriptionChecker.check_all_channels(
|
||||
bot, user_id, self.channels
|
||||
)
|
||||
|
||||
# Логика проверки
|
||||
if self.require_all:
|
||||
# Все каналы обязательны
|
||||
is_passed = all(results.values())
|
||||
else:
|
||||
# Хотя бы один канал
|
||||
is_passed = any(results.values())
|
||||
|
||||
# Логирование
|
||||
if not is_passed:
|
||||
not_subscribed = [ch for ch, sub in results.items() if not sub]
|
||||
logger.info(
|
||||
f"Пользователь {user_id} не подписан на: {not_subscribed}",
|
||||
log_type='SUBSCRIPTION',
|
||||
message=event if isinstance(event, Message) else None
|
||||
)
|
||||
|
||||
# Возвращаем результат + детали для handler
|
||||
return {
|
||||
'is_subscribed': is_passed,
|
||||
'subscription_results': results,
|
||||
'not_subscribed_channels': [ch for ch, sub in results.items() if not sub]
|
||||
} if not is_passed else is_passed
|
||||
14
bot/handlers/__init__.py
Normal file
14
bot/handlers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .commands import router as cmd_routers
|
||||
from .messages import router as messages_routers
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
# Подключение роутеров
|
||||
router.include_routers(
|
||||
cmd_routers,
|
||||
messages_routers,
|
||||
)
|
||||
16
bot/handlers/commands/__init__.py
Normal file
16
bot/handlers/commands/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from aiogram import Router
|
||||
|
||||
#from .admins import router as admin_cmd_router
|
||||
from .users import router as users_cmd_router
|
||||
#from .settings import router as settings_cmd_router
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
# Подключение роутеров
|
||||
router.include_routers(
|
||||
#settings_cmd_router,
|
||||
#admin_cmd_router,
|
||||
users_cmd_router,
|
||||
)
|
||||
18
bot/handlers/commands/admins/__init__.py
Normal file
18
bot/handlers/commands/admins/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from aiogram import Router
|
||||
|
||||
#from .ban_cmd import router as ban_cmd_router
|
||||
from .all_cmd import router as all_cmd_router
|
||||
from .pin_cmd import router as pin_cmd_router
|
||||
from .kick_cmd import router as kick_cmd_router
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
router.include_routers(
|
||||
#ban_cmd_router,
|
||||
kick_cmd_router,
|
||||
pin_cmd_router,
|
||||
all_cmd_router,
|
||||
|
||||
)
|
||||
81
bot/handlers/commands/admins/all_cmd.py
Normal file
81
bot/handlers/commands/admins/all_cmd.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from asyncio import create_task
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from bot.core.bots import bot, BotInfo
|
||||
from bot.filters import IsOwner
|
||||
from bot.utils import status_clear, auto_delete_message, hidden_admins_message
|
||||
from configs import COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
# Ключ для команды
|
||||
CMD: str = "all"
|
||||
# Инициализация роутера
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
@router.message(
|
||||
F.text.lower().regexp(rf"^({'|'.join(COMMANDS[CMD])})\s?.*"), # ловим текст без префикса
|
||||
F.chat.type.in_({"supergroup", "group"}),
|
||||
IsOwner()
|
||||
)
|
||||
@router.message(
|
||||
Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True),
|
||||
F.chat.type.in_({"supergroup", "group"}),
|
||||
IsOwner()
|
||||
)
|
||||
async def notify_all_text(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Обработчик команды /all, /call и текстовых эквивалентов типа "Калл Привет всем".
|
||||
|
||||
Функционал:
|
||||
1. Считывает весь текст после команды.
|
||||
2. Формирует скрытое сообщение для администраторов.
|
||||
3. Отправляет сообщение в чат.
|
||||
4. Автоматически удаляет сообщение через неделю.
|
||||
5. Пытается закрепить сообщение в чате.
|
||||
|
||||
Args:
|
||||
message (Message): Объект входящего сообщения.
|
||||
state (FSMContext): Контекст FSM, используется для очистки состояния.
|
||||
"""
|
||||
# Очистка состояния FSM перед выполнением команды
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
# Извлечение текста после команды
|
||||
parts: list[str] = message.text.split(" ", 1)
|
||||
custom_text: str = parts[1] if len(parts) > 1 else "⚡ Внимание всем!"
|
||||
|
||||
# Формирование скрытого текста для администраторов
|
||||
hidden_text: str = await hidden_admins_message(message=message, text=custom_text)
|
||||
|
||||
# Отправка сообщения в чат
|
||||
sent_message: Message = await message.answer(hidden_text)
|
||||
|
||||
# Запуск асинхронной задачи по удалению сообщения через 7 дней
|
||||
create_task(
|
||||
auto_delete_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
delay=604800 # 7 дней в секундах
|
||||
)
|
||||
)
|
||||
|
||||
# Попытка закрепить сообщение и удалить "системное" сообщение о закреплении
|
||||
try:
|
||||
await bot.pin_chat_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
disable_notification=False
|
||||
)
|
||||
# Иногда Telegram создает дополнительное уведомление при закреплении
|
||||
await bot.delete_message(chat_id=message.chat.id, message_id=sent_message.message_id + 1)
|
||||
logger.debug(f"[ALL] Сообщение закреплено: {custom_text}")
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"[ALL] Ошибка закрепления сообщения: {e}")
|
||||
258
bot/handlers/commands/admins/ban_cmd.py
Normal file
258
bot/handlers/commands/admins/ban_cmd.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message, User
|
||||
from html import escape
|
||||
|
||||
from bot.filters import IsAdmin
|
||||
from bot.utils import status_clear
|
||||
from configs import COMMANDS
|
||||
from database import db
|
||||
|
||||
# Настройки роутера
|
||||
__all__ = ("router",)
|
||||
|
||||
from middleware import logger
|
||||
|
||||
CMD: str = "ban"
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin())
|
||||
async def ban_user_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /ban для блокировки пользователей.
|
||||
Использование: /ban <user_id> или ответ на сообщение пользователя + /ban
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
try:
|
||||
# Проверяем есть ли ответ на сообщение
|
||||
if message.reply_to_message:
|
||||
# Бан по ответу на сообщение
|
||||
target_user: User | None = message.reply_to_message.from_user
|
||||
if not target_user:
|
||||
await message.answer("❌ Не удалось определить пользователя")
|
||||
return
|
||||
|
||||
target_user_id: int = target_user.id
|
||||
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
|
||||
|
||||
# Проверяем, не пытаемся ли забанить бота
|
||||
if target_user_id == message.bot.id:
|
||||
await message.answer("❌ Нельзя заблокировать бота!")
|
||||
return
|
||||
|
||||
# Баним пользователя
|
||||
success: bool = await _ban_user(target_user_id, target_username, message)
|
||||
|
||||
if success:
|
||||
safe_username: str = escape(target_username)
|
||||
response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) заблокирован!"
|
||||
|
||||
# Пытаемся забанить в чате (если команда вызвана в группе/чате)
|
||||
if message.chat.type in ["group", "supergroup"]:
|
||||
try:
|
||||
await message.bot.ban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=target_user_id
|
||||
)
|
||||
response_text += "\n🚫 Пользователь исключен из чата."
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось исключить пользователя из чата: {e}")
|
||||
response_text += "\n⚠️ Не удалось исключить пользователя из чата."
|
||||
|
||||
await message.answer(
|
||||
text=response_text,
|
||||
parse_mode=None # Отключаем разметку
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Не удалось заблокировать пользователя")
|
||||
|
||||
else:
|
||||
# Бан по ID пользователя
|
||||
command_parts: list[str] = message.text.split()
|
||||
if len(command_parts) < 2:
|
||||
await message.answer(
|
||||
"ℹ️ Использование команды:\n"
|
||||
"• Ответьте на сообщение пользователя командой /ban\n"
|
||||
"• Или укажите ID: /ban <user_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id: int = int(command_parts[1])
|
||||
|
||||
# Проверяем, не пытаемся ли забанить бота
|
||||
if target_user_id == message.bot.id:
|
||||
await message.answer("❌ Нельзя заблокировать бота!")
|
||||
return
|
||||
|
||||
success: bool = await _ban_user(target_user_id, f"ID{target_user_id}", message)
|
||||
|
||||
if success:
|
||||
response_text = f"✅ Пользователь (ID: {target_user_id}) заблокирован!"
|
||||
|
||||
# Пытаемся забанить в чате
|
||||
if message.chat.type in ["group", "supergroup"]:
|
||||
try:
|
||||
await message.bot.ban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=target_user_id
|
||||
)
|
||||
response_text += "\n🚫 Пользователь исключен из чата."
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось исключить пользователя из чата: {e}")
|
||||
response_text += "\n⚠️ Не удалось исключить пользователя из чата."
|
||||
|
||||
await message.answer(
|
||||
text=response_text,
|
||||
parse_mode=None
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Пользователь не найден или уже заблокирован")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат ID пользователя")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в команде /ban: {e}")
|
||||
await message.answer(
|
||||
"⚠️ Произошла непредвиденная ошибка при выполнении команды.\n"
|
||||
"Попробуйте повторить действие позже или нажмите /start"
|
||||
)
|
||||
|
||||
|
||||
async def _ban_user(user_id: int, username: str, message: Message) -> bool:
|
||||
"""
|
||||
Внутренняя функция для блокировки пользователя.
|
||||
"""
|
||||
try:
|
||||
# Сначала проверяем существует ли пользователь
|
||||
user: User | None = await db.get_user(user_id)
|
||||
|
||||
if not user:
|
||||
# Если пользователя нет - создаем его забаненным
|
||||
await db.add_user(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
full_name=username
|
||||
)
|
||||
|
||||
# Баним пользователя
|
||||
await db.ban_user(user_id)
|
||||
|
||||
# Логируем действие
|
||||
admin_username = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}"
|
||||
logger.info(f"🛑 Админ @{admin_username} заблокировал пользователя @{username} (ID: {user_id})")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при блокировке пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.message(Command("unban", ignore_case=True), IsAdmin())
|
||||
async def unban_user_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /unban для разблокировки пользователей.
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
try:
|
||||
if message.reply_to_message:
|
||||
target_user: User | None = message.reply_to_message.from_user
|
||||
if not target_user:
|
||||
await message.answer("❌ Не удалось определить пользователя")
|
||||
return
|
||||
|
||||
target_user_id: int = target_user.id
|
||||
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
|
||||
else:
|
||||
command_parts: list[str] = message.text.split()
|
||||
if len(command_parts) < 2:
|
||||
await message.answer(
|
||||
"ℹ️ Использование команды:\n"
|
||||
"• Ответьте на сообщение пользователя командой /unban\n"
|
||||
"• Или укажите ID: /unban <user_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id: int = int(command_parts[1])
|
||||
target_username: str = f"ID{target_user_id}"
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат ID пользователя")
|
||||
return
|
||||
|
||||
# Разбаниваем пользователя
|
||||
await db.unban_user(target_user_id)
|
||||
|
||||
# Логируем действие
|
||||
admin_username: str = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}"
|
||||
logger.info(f"🔓 Админ @{admin_username} разблокировал пользователя @{target_username} (ID: {target_user_id})")
|
||||
|
||||
# Экранируем специальные символы
|
||||
safe_username: str = escape(target_username)
|
||||
|
||||
response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) разблокирован!"
|
||||
|
||||
# Пытаемся разбанить в чате
|
||||
if message.chat.type in ["group", "supergroup"]:
|
||||
try:
|
||||
await message.bot.unban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=target_user_id
|
||||
)
|
||||
response_text += "\n👥 Пользователь может вернуться в чат."
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось разблокировать пользователя в чате: {e}")
|
||||
|
||||
await message.answer(
|
||||
text=response_text,
|
||||
parse_mode=None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при разблокировке пользователя: {e}")
|
||||
await message.answer("❌ Не удалось разблокировать пользователя")
|
||||
|
||||
|
||||
@router.message(Command("banned_list", ignore_case=True), IsAdmin())
|
||||
async def banned_list_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /banned_list для просмотра списка забаненных пользователей.
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
try:
|
||||
# Получаем всех пользователей включая забаненных
|
||||
all_users: list[User] = await db.get_all_users(include_banned=True)
|
||||
|
||||
# Фильтруем только забаненных
|
||||
banned_users: list[User] = [user for user in all_users if getattr(user, 'status', None) == "banned"]
|
||||
|
||||
if not banned_users:
|
||||
await message.answer("📭 Список забаненных пользователей пуст")
|
||||
return
|
||||
|
||||
# Формируем сообщение со списком
|
||||
banned_list: str = "🚫 Заблокированные пользователи:\n\n"
|
||||
|
||||
for user in banned_users[:50]: # Ограничиваем вывод
|
||||
username: str = f"@{user.username}" if getattr(user, 'username', None) else getattr(user, 'full_name',
|
||||
'Неизвестно')
|
||||
# Экранируем специальные символы
|
||||
safe_username = escape(username)
|
||||
user_id = getattr(user, 'id', 'N/A')
|
||||
banned_list += f"• {safe_username} (ID: {user_id})\n"
|
||||
|
||||
if len(banned_users) > 50:
|
||||
banned_list += f"\n... и еще {len(banned_users) - 50} пользователей"
|
||||
|
||||
await message.answer(banned_list, parse_mode=None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении списка забаненных: {e}")
|
||||
await message.answer("❌ Не удалось получить список забаненных пользователей")
|
||||
277
bot/handlers/commands/admins/kick_cmd.py
Normal file
277
bot/handlers/commands/admins/kick_cmd.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message, User
|
||||
from html import escape
|
||||
|
||||
from bot import bot
|
||||
from bot.filters import IsAdmin
|
||||
from bot.utils import status_clear
|
||||
from configs import COMMANDS
|
||||
|
||||
# Настройки роутера
|
||||
__all__ = ("router",)
|
||||
|
||||
from middleware import logger
|
||||
|
||||
CMD: str = "kick"
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin())
|
||||
async def kick_user_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /kick для кика пользователей из чата.
|
||||
Использование: /kick <user_id> или ответ на сообщение пользователя + /kick
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
# Проверяем, что команда используется в группе/супергруппе
|
||||
if message.chat.type not in ["group", "supergroup"]:
|
||||
await message.answer("❌ Эта команда работает только в группах и супергруппах!")
|
||||
return
|
||||
|
||||
# Проверяем есть ли ответ на сообщение
|
||||
if message.reply_to_message:
|
||||
# Кик по ответу на сообщение
|
||||
target_user: User | None = message.reply_to_message.from_user
|
||||
target_user_id: int = target_user.id
|
||||
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
|
||||
|
||||
# Кикаем пользователя
|
||||
success: bool = await _kick_user(target_user_id, target_username, message)
|
||||
|
||||
if success:
|
||||
safe_username: str = escape(target_username)
|
||||
await message.answer(
|
||||
text=f"👢 Пользователь {safe_username} (ID: {target_user_id}) кикнут из чата!",
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Не удалось кикнуть пользователя")
|
||||
|
||||
else:
|
||||
# Кик по ID пользователя
|
||||
command_parts: list[str] = message.text.split()
|
||||
if len(command_parts) < 2:
|
||||
await message.answer(
|
||||
"ℹ️ Использование команды:\n"
|
||||
"• Ответьте на сообщение пользователя командой /kick\n"
|
||||
"• Или укажите ID: /kick <user_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id: int = int(command_parts[1])
|
||||
success: bool = await _kick_user(target_user_id, f"ID{target_user_id}", message)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
text=f"👢 Пользователь (ID: {target_user_id}) кикнут из чата!",
|
||||
parse_mode=None # Отключаем разметку
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Пользователь не найден или не удалось кикнуть")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат ID пользователя")
|
||||
|
||||
|
||||
async def _kick_user(user_id: int, username: str, message: Message) -> bool:
|
||||
"""
|
||||
Внутренняя функция для кика пользователя из чата.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя для кика
|
||||
username: Имя пользователя для логов
|
||||
message: Объект сообщения для контекста
|
||||
|
||||
Returns:
|
||||
bool: Успешно ли кикнут пользователь
|
||||
"""
|
||||
try:
|
||||
# Проверяем, что бот имеет права администратора в чате
|
||||
bot_member = await bot.get_chat_member(message.chat.id, bot.id)
|
||||
if not bot_member.can_restrict_members:
|
||||
await message.answer("❌ У меня нет прав для кика пользователей!")
|
||||
return False
|
||||
|
||||
# Проверяем, что целевой пользователь не является администратором/владельцем
|
||||
target_member = await bot.get_chat_member(message.chat.id, user_id)
|
||||
if target_member.status in ["creator", "administrator"]:
|
||||
await message.answer("❌ Нельзя кикнуть администратора или создателя чата!")
|
||||
return False
|
||||
|
||||
# Проверяем, что отправитель команды имеет права администратора
|
||||
admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id)
|
||||
if admin_member.status not in ["creator", "administrator"]:
|
||||
await message.answer("❌ У вас нет прав для кика пользователей!")
|
||||
return False
|
||||
|
||||
# Кикаем пользователя из чата
|
||||
await bot.ban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=user_id,
|
||||
revoke_messages=False # Не удаляем сообщения пользователя
|
||||
)
|
||||
|
||||
# Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению
|
||||
await bot.unban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Логируем действие
|
||||
admin_username = message.from_user.username or message.from_user.full_name
|
||||
logger.info(
|
||||
f"👢 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при кике пользователя {user_id}: {e}")
|
||||
await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
@router.message(Command("kick_ban", ignore_case=True), IsAdmin())
|
||||
async def kick_ban_user_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /kick_ban для кика пользователя с удалением сообщений.
|
||||
Использование: /kick_ban <user_id> или ответ на сообщение пользователя + /kick_ban
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
# Проверяем, что команда используется в группе/супергруппе
|
||||
if message.chat.type not in ["group", "supergroup"]:
|
||||
await message.answer("❌ Эта команда работает только в группах и супергруппах!")
|
||||
return
|
||||
|
||||
# Проверяем есть ли ответ на сообщение
|
||||
if message.reply_to_message:
|
||||
# Кик по ответу на сообщение
|
||||
target_user: User | None = message.reply_to_message.from_user
|
||||
target_user_id: int = target_user.id
|
||||
target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}"
|
||||
|
||||
# Кикаем пользователя с удалением сообщений
|
||||
success: bool = await _kick_ban_user(target_user_id, target_username, message)
|
||||
|
||||
if success:
|
||||
safe_username: str = escape(target_username)
|
||||
await message.answer(
|
||||
text=f"💥 Пользователь {safe_username} (ID: {target_user_id}) кикнут с удалением сообщений!",
|
||||
parse_mode=None # Отключаем разметку
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Не удалось кикнуть пользователя")
|
||||
|
||||
else:
|
||||
# Кик по ID пользователя
|
||||
command_parts: list[str] = message.text.split()
|
||||
if len(command_parts) < 2:
|
||||
await message.answer(
|
||||
"ℹ️ Использование команды:\n"
|
||||
"• Ответьте на сообщение пользователя командой /kick_ban\n"
|
||||
"• Или укажите ID: /kick_ban <user_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id: int = int(command_parts[1])
|
||||
success: bool = await _kick_ban_user(target_user_id, f"ID{target_user_id}", message)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
text=f"💥 Пользователь (ID: {target_user_id}) кикнут с удалением сообщений!",
|
||||
parse_mode=None # Отключаем разметку
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Пользователь не найден или не удалось кикнуть")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат ID пользователя")
|
||||
|
||||
|
||||
async def _kick_ban_user(user_id: int, username: str, message: Message) -> bool:
|
||||
"""
|
||||
Внутренняя функция для кика пользователя с удалением сообщений.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя для кика
|
||||
username: Имя пользователя для логов
|
||||
message: Объект сообщения для контекста
|
||||
|
||||
Returns:
|
||||
bool: Успешно ли кикнут пользователь
|
||||
"""
|
||||
try:
|
||||
# Проверяем, что бот имеет права администратора в чате
|
||||
bot_member = await bot.get_chat_member(message.chat.id, bot.id)
|
||||
if not bot_member.can_restrict_members:
|
||||
await message.answer("❌ У меня нет прав для кика пользователей!")
|
||||
return False
|
||||
|
||||
# Проверяем, что целевой пользователь не является администратором/владельцем
|
||||
target_member = await bot.get_chat_member(message.chat.id, user_id)
|
||||
if target_member.status in ["creator", "administrator"]:
|
||||
await message.answer("❌ Нельзя кикнуть администратора или создателя чата!")
|
||||
return False
|
||||
|
||||
# Проверяем, что отправитель команды имеет права администратора
|
||||
admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id)
|
||||
if admin_member.status not in ["creator", "administrator"]:
|
||||
await message.answer("❌ У вас нет прав для кика пользователей!")
|
||||
return False
|
||||
|
||||
# Кикаем пользователя из чата с удалением сообщений
|
||||
await bot.ban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=user_id,
|
||||
revoke_messages=True # Удаляем сообщения пользователя
|
||||
)
|
||||
|
||||
# Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению
|
||||
await bot.unban_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Логируем действие
|
||||
admin_username = message.from_user.username or message.from_user.full_name
|
||||
logger.info(
|
||||
f"💥 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title} с удалением сообщений")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при кике пользователя {user_id} с удалением сообщений: {e}")
|
||||
await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
@router.message(Command("kick_list", ignore_case=True), IsAdmin())
|
||||
async def kick_help_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Команда /kick_list для показа справки по командам кика.
|
||||
"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
help_text = """
|
||||
🤖 **Команды модерации:**
|
||||
|
||||
**👢 /kick** - Кикнуть пользователя (может вернуться по приглашению)
|
||||
• Ответьте на сообщение пользователя с командой /kick
|
||||
• Или используйте: /kick <user_id>
|
||||
|
||||
**💥 /kick_ban** - Кикнуть пользователя с удалением сообщений
|
||||
• Ответьте на сообщение пользователя с командой /kick_ban
|
||||
• Или используйте: /kick_ban <user_id>
|
||||
|
||||
**🚫 /ban** - Полностью забанить пользователя
|
||||
**🔓 /unban** - Разбанить пользователя
|
||||
**📋 /banned_list** - Список забаненных
|
||||
|
||||
⚠️ *Команды работают только в группах и требуют прав администратора*
|
||||
"""
|
||||
|
||||
await message.answer(help_text, parse_mode=None)
|
||||
77
bot/handlers/commands/admins/pin_cmd.py
Normal file
77
bot/handlers/commands/admins/pin_cmd.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from asyncio import create_task
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from bot.core.bots import BotInfo, bot
|
||||
from bot.filters import IsOwner
|
||||
from bot.templates import msg
|
||||
from bot.utils import status_clear
|
||||
from bot.utils.auto_delete import auto_delete_message
|
||||
from configs import COMMANDS
|
||||
|
||||
__all__ = ("router",)
|
||||
CMD: str = "pin".lower()
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
|
||||
async def pin_cmd(message: Message, state: FSMContext) -> None:
|
||||
"""
|
||||
Обработчик команды /pin для закрепления последнего сообщения или ответа.
|
||||
"""
|
||||
# Если есть reply → закрепляем его, иначе закрепляем предыдущее сообщение
|
||||
if message.reply_to_message:
|
||||
target_message_id = message.reply_to_message.message_id
|
||||
else:
|
||||
# Закрепляем предыдущее сообщение (команда - 1)
|
||||
target_message_id = message.message_id - 1
|
||||
|
||||
try:
|
||||
await bot.pin_chat_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=target_message_id,
|
||||
disable_notification=False
|
||||
)
|
||||
|
||||
# Автоудаление через 7 суток (удаляем закрепленное сообщение)
|
||||
create_task(
|
||||
auto_delete_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=target_message_id,
|
||||
delay=604800
|
||||
)
|
||||
)
|
||||
|
||||
await msg(update=message, text="✅ Сообщение успешно закреплено", state=state)
|
||||
|
||||
except Exception as e:
|
||||
await msg(update=message, text=f"❌ Ошибка закрепления: {e}", state=state)
|
||||
|
||||
|
||||
@router.callback_query(F.data.casefold().isin(COMMANDS[CMD]), IsOwner())
|
||||
async def pin_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
"""
|
||||
Обработчик кнопки с callback_data="pin".
|
||||
"""
|
||||
await status_clear(update=callback.message, state=state)
|
||||
|
||||
try:
|
||||
await bot.pin_chat_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
message_id=callback.message.message_id,
|
||||
disable_notification=False
|
||||
)
|
||||
|
||||
create_task(
|
||||
auto_delete_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
message_id=callback.message.message_id,
|
||||
delay=604800
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer("✅ Сообщение закреплено")
|
||||
|
||||
except Exception as e:
|
||||
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
|
||||
51
bot/handlers/commands/admins/settings_cmd.py
Normal file
51
bot/handlers/commands/admins/settings_cmd.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.utils.i18n import gettext as _
|
||||
|
||||
from bot.templates import msg_photo
|
||||
from bot.utils.interesting_facts import interesting_fact
|
||||
from bot.core.bots import BotInfo
|
||||
from configs import COMMANDS, RpValue
|
||||
|
||||
# Настройки экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
CMD: str = "settings".lower()
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.lower() == CMD)
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
|
||||
async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
|
||||
"""Обработчик команды /start"""
|
||||
await state.clear()
|
||||
|
||||
# Создание инлайн-клавиатуры
|
||||
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
|
||||
ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=CustomConfig.INFO_URL))
|
||||
ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'),
|
||||
InlineKeyboardButton(text="Анкета📖", callback_data='anketa'))
|
||||
ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin'))
|
||||
|
||||
# Формируем приветственное сообщение
|
||||
text: str = _(
|
||||
"""Добро пожаловать, <a href="{url}">{name}</a>!
|
||||
|
||||
Я ваш искусственный помощник по ролевой - <b>{rp_name}</b>!
|
||||
Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще!
|
||||
Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
|
||||
|
||||
Интересный факт:
|
||||
<blockquote>{fact}</blockquote>
|
||||
"""
|
||||
).format(
|
||||
url=message.from_user.url if message.from_user else "",
|
||||
name=message.from_user.first_name if message.from_user else "пользователь",
|
||||
rp_name=RpValue.RP_NAME,
|
||||
fact=interesting_fact(),
|
||||
)
|
||||
|
||||
# Отправляем сообщение
|
||||
await msg_photo(update=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb)
|
||||
19
bot/handlers/commands/settings/__init__.py
Normal file
19
bot/handlers/commands/settings/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .set_description_cmd import router as set_description_cmd_router
|
||||
from .set_name_cmd import router as set_name_cmd_router
|
||||
from .set_widget_cmd import router as set_widget_cmd_router
|
||||
from .settings_cmd import router as settings_cmd_router
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
|
||||
# Подключение роутеров
|
||||
router.include_routers(
|
||||
settings_cmd_router,
|
||||
set_name_cmd_router,
|
||||
set_description_cmd_router,
|
||||
set_widget_cmd_router,
|
||||
)
|
||||
173
bot/handlers/commands/settings/set_description_cmd.py
Normal file
173
bot/handlers/commands/settings/set_description_cmd.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.i18n import gettext as _
|
||||
|
||||
from bot.core.bots import BotInfo
|
||||
from bot.filters import IsOwner
|
||||
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
|
||||
from bot.templates import msg
|
||||
from bot.utils import format_retry_time, status_clear
|
||||
from configs import COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
# Название команды
|
||||
CMD: str = "set_description".lower()
|
||||
|
||||
# Роутер для обработки команды /set_description
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
class SetBotDescriptionForm(StatesGroup):
|
||||
"""Состояния FSM для изменения короткого описания бота."""
|
||||
new_description: State = State()
|
||||
|
||||
|
||||
async def handle_set_bot_description(
|
||||
description: str,
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot
|
||||
) -> None:
|
||||
"""
|
||||
Установка короткого описания (short description) бота с обработкой FSM и ошибок API.
|
||||
|
||||
Args:
|
||||
description (str): Новый текст описания (до 120 символов).
|
||||
message (Message | CallbackQuery): Сообщение или callback-запрос.
|
||||
state (FSMContext): Контекст FSM.
|
||||
bot (Bot): Экземпляр бота.
|
||||
"""
|
||||
# Проверка ограничения Telegram
|
||||
if len(description) > 120:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Короткое описание бота должно быть не более 120 символов. Текущая длина: {length}").format(
|
||||
length=len(description)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Установка нового короткого описания
|
||||
await bot.set_my_short_description(short_description=description)
|
||||
|
||||
# Сохраняем текущее значение в BotInfo
|
||||
BotInfo.short_description = description
|
||||
|
||||
# Сбрасываем состояние FSM
|
||||
await state.clear()
|
||||
|
||||
# Отправляем сообщение об успехе
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("✅ Короткое описание бота успешно изменено на: <b>{description}</b>").format(
|
||||
description=description
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
logger.info(f"Короткое описание бота изменено на: {description}")
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_text: str = format_retry_time(e.retry_after)
|
||||
logger.warning(f"Превышен лимит запросов при смене short description. Попробуйте через {retry_text}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("⚠️ Слишком частая смена короткого описания!\nПопробуйте снова через: <b>{retry_text}</b>").format(
|
||||
retry_text=retry_text
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(f"Ошибка Telegram API при изменении короткого описания: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Ошибка Telegram API при изменении короткого описания: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Непредвиденная ошибка при изменении короткого описания: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Непредвиденная ошибка при изменении короткого описания: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.lower() == CMD, IsOwner())
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
|
||||
async def settings_cmd(
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
command: CommandObject | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Обработчик команды /set_description для короткого описания.
|
||||
|
||||
Поддерживает:
|
||||
1. Немедленное изменение через аргумент (/set_description TEXT).
|
||||
2. Callback-запрос.
|
||||
3. FSM-ввод.
|
||||
"""
|
||||
current_description: str = BotInfo.description
|
||||
|
||||
# Вариант 1: если пользователь передал аргумент к команде
|
||||
if command and command.args:
|
||||
description: str = command.args.strip()
|
||||
if len(description) > 120:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Короткое описание не должно превышать 120 символов. Текущая длина: {length}").format(
|
||||
length=len(description)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
|
||||
await handle_set_bot_description(description, message, state, bot)
|
||||
return
|
||||
|
||||
# Вариант 2: без аргумента → включаем FSM
|
||||
await status_clear(update=message, state=state)
|
||||
text: str = _(
|
||||
"📝 <b>Смена короткого описания бота</b>\n\n"
|
||||
"Текущее короткое описание: <i>{current}</i>\n\n"
|
||||
"Введите новое короткое описание (максимум 120 символов):"
|
||||
).format(current=current_description)
|
||||
|
||||
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
|
||||
await state.set_state(SetBotDescriptionForm.new_description)
|
||||
|
||||
|
||||
@router.message(SetBotDescriptionForm.new_description, IsOwner())
|
||||
async def process_new_bot_description(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
bot: Bot
|
||||
) -> None:
|
||||
"""
|
||||
Обработка ввода нового короткого описания через FSM.
|
||||
"""
|
||||
description: str = message.text.strip()
|
||||
|
||||
if not description:
|
||||
await message.answer(_("❌ Пожалуйста, введите корректное короткое описание."))
|
||||
return
|
||||
|
||||
await handle_set_bot_description(description, message, state, bot)
|
||||
157
bot/handlers/commands/settings/set_name_cmd.py
Normal file
157
bot/handlers/commands/settings/set_name_cmd.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.i18n import gettext as _
|
||||
|
||||
from bot.core.bots import BotInfo
|
||||
from bot.filters import IsOwner
|
||||
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
|
||||
from bot.templates import msg
|
||||
from configs import COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
CMD: str = "set_name".lower()
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
class SetNameForm(StatesGroup):
|
||||
new_name: State = State()
|
||||
|
||||
|
||||
def format_retry_time(retry_after: int) -> str:
|
||||
"""Форматирование времени повторной попытки в читаемом виде"""
|
||||
hours, remainder = divmod(retry_after, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours} часов, {minutes} минут, {seconds} секунд"
|
||||
elif minutes > 0:
|
||||
return f"{minutes} минут, {seconds} секунд"
|
||||
else:
|
||||
return f"{seconds} секунд"
|
||||
|
||||
|
||||
async def handle_set_name(
|
||||
new_name: str,
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot
|
||||
) -> None:
|
||||
"""
|
||||
Установка имени бота с проверкой длины, обработкой перегрузки и логированием
|
||||
"""
|
||||
if len(new_name) > 64:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Имя бота должно быть не более 64 символов. Текущая длина: {length}").format(
|
||||
length=len(new_name)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.set_my_name(new_name)
|
||||
BotInfo.first_name = new_name
|
||||
await state.clear()
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("✅ Имя бота успешно изменено на: <b>{new_name}</b>").format(new_name=new_name),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
logger.info(f"Имя бота изменено на: {new_name}")
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_text: str = format_retry_time(e.retry_after)
|
||||
logger.warning(f"Превышен контроль перегрузки при смене имени. Попробуйте через {retry_text}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("⚠️ Слишком частая смена имени!\nПопробуйте снова через: <b>{retry_text}</b>").format(
|
||||
retry_text=retry_text
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(f"Ошибка Telegram API при изменении имени: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Ошибка Telegram API: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Непредвиденная ошибка при изменении имени: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Непредвиденная ошибка: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.lower() == CMD, IsOwner())
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
|
||||
async def settings_cmd(
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
command: CommandObject | None = None
|
||||
):
|
||||
"""
|
||||
Обработчик команды /set_name с поддержкой:
|
||||
1. Immediate установки через аргумент команды
|
||||
2. Callback query
|
||||
3. FSM ввод
|
||||
"""
|
||||
current_name = getattr(BotInfo, "first_name", "") or _("Не установлено")
|
||||
|
||||
# Immediate установка через аргумент команды
|
||||
if command and command.args:
|
||||
new_name = command.args.strip()
|
||||
if len(new_name) > 64:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Имя не должно превышать 64 символа. Текущая длина: {length}").format(
|
||||
length=len(new_name)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
await handle_set_name(new_name, message, state, bot)
|
||||
return
|
||||
|
||||
# Для callback query или пустой команды — показываем текущее имя и запускаем FSM
|
||||
await state.clear()
|
||||
if isinstance(message, CallbackQuery):
|
||||
await message.answer()
|
||||
text: str = _(
|
||||
"🤖 <b>Смена имени бота</b>\n\n"
|
||||
"Текущее имя: <i>{current}</i>\n\n"
|
||||
"Пожалуйста, введите новое имя для бота (максимум 64 символа):"
|
||||
).format(current=current_name)
|
||||
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
|
||||
await state.set_state(SetNameForm.new_name)
|
||||
|
||||
|
||||
@router.message(SetNameForm.new_name, IsOwner())
|
||||
async def process_new_name(message: Message, state: FSMContext, bot: Bot):
|
||||
"""
|
||||
Обработка ввода нового имени через FSM
|
||||
"""
|
||||
new_name: str = message.text.strip()
|
||||
|
||||
if not new_name:
|
||||
await message.answer(_("❌ Пожалуйста, введите корректное имя."))
|
||||
return
|
||||
|
||||
await handle_set_name(new_name, message, state, bot)
|
||||
174
bot/handlers/commands/settings/set_widget_cmd.py
Normal file
174
bot/handlers/commands/settings/set_widget_cmd.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||
from aiogram.filters import Command, CommandObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.i18n import gettext as _
|
||||
|
||||
from bot.core.bots import BotInfo
|
||||
from bot.filters import IsOwner
|
||||
from bot.handlers.commands.settings.settings_cmd import settings_keyboard
|
||||
from bot.templates import msg
|
||||
from bot.utils import format_retry_time, status_clear
|
||||
from configs import COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
CMD: str = "set_widget".lower()
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
class SetWidgetForm(StatesGroup):
|
||||
"""Состояния FSM для изменения виджета (описания бота)."""
|
||||
new_widget: State = State()
|
||||
|
||||
|
||||
async def handle_set_widget(
|
||||
new_widget: str,
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot
|
||||
) -> None:
|
||||
"""
|
||||
Устанавливает новое значение виджета (описания бота).
|
||||
|
||||
Args:
|
||||
new_widget (str): Новый текст виджета.
|
||||
message (Message | CallbackQuery): Объект сообщения или callback-запроса.
|
||||
state (FSMContext): Контекст состояния FSM.
|
||||
bot (Bot): Экземпляр текущего бота.
|
||||
"""
|
||||
# Проверка длины текста (Telegram API ограничивает description до 512 символов)
|
||||
if len(new_widget) > 512:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Виджет бота должен быть не более 512 символов. Текущая длина: {length}").format(
|
||||
length=len(new_widget)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Устанавливаем описание через Telegram API
|
||||
await bot.set_my_description(description=new_widget)
|
||||
|
||||
# Сохраняем в BotInfo для локального использования
|
||||
BotInfo.widget = new_widget
|
||||
|
||||
# Очищаем состояние FSM
|
||||
await state.clear()
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("✅ Виджет бота успешно изменён на: <b>{new_widget}</b>").format(
|
||||
new_widget=new_widget
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
logger.info(f"Виджет бота изменён на: {new_widget}")
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# Если запрос слишком частый
|
||||
retry_text: str = format_retry_time(e.retry_after)
|
||||
logger.warning(f"Превышен лимит запросов при смене виджета. Попробуйте через {retry_text}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("⚠️ Слишком частая смена виджета!\nПопробуйте снова через: <b>{retry_text}</b>").format(
|
||||
retry_text=retry_text
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except TelegramAPIError as e:
|
||||
# Ошибка Telegram API
|
||||
logger.error(f"Ошибка Telegram API при изменении виджета: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Ошибка Telegram API при изменении виджета: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Непредвиденная ошибка
|
||||
logger.error(f"Непредвиденная ошибка при изменении виджета: {e}")
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Непредвиденная ошибка при изменении виджета: <pre>{error}</pre>").format(error=str(e)),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.lower() == CMD, IsOwner())
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
|
||||
async def settings_cmd(
|
||||
message: Message | CallbackQuery,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
command: CommandObject | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Обработчик команды /set_widget.
|
||||
|
||||
Поддерживает:
|
||||
1. Немедленное изменение через аргумент команды (/set_widget TEXT).
|
||||
2. Callback-запрос.
|
||||
3. FSM ввод.
|
||||
"""
|
||||
# Получаем текущее значение виджета
|
||||
current_widget: str = BotInfo.short_description
|
||||
|
||||
# Вариант 1: пользователь ввёл аргумент сразу (/set_widget TEXT)
|
||||
if command and command.args:
|
||||
new_widget: str = command.args.strip()
|
||||
if len(new_widget) > 512:
|
||||
await msg(
|
||||
update=message,
|
||||
text=_("❌ Виджет не должен превышать 512 символов. Текущая длина: {length}").format(
|
||||
length=len(new_widget)
|
||||
),
|
||||
markup=settings_keyboard(),
|
||||
state=state
|
||||
)
|
||||
return
|
||||
|
||||
await handle_set_widget(new_widget, message, state, bot)
|
||||
return
|
||||
|
||||
# Вариант 2: Callback query или пустая команда → запускаем FSM
|
||||
await status_clear(update=message, state=state)
|
||||
text: str = _(
|
||||
"📝 <b>Смена виджета бота</b>\n\n"
|
||||
"Текущий виджет: <i>{current}</i>\n\n"
|
||||
"Пожалуйста, введите новый виджет для бота (максимум 512 символов):"
|
||||
).format(current=current_widget)
|
||||
|
||||
await msg(update=message, text=text, markup=settings_keyboard(), state=state)
|
||||
await state.set_state(SetWidgetForm.new_widget)
|
||||
|
||||
|
||||
@router.message(SetWidgetForm.new_widget, IsOwner())
|
||||
async def process_new_widget(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
bot: Bot
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает ввод нового текста виджета через FSM.
|
||||
"""
|
||||
new_widget: str = message.text.strip()
|
||||
|
||||
# Проверяем, что пользователь что-то ввёл
|
||||
if not new_widget:
|
||||
await message.answer(_("❌ Пожалуйста, введите корректный виджет."))
|
||||
return
|
||||
|
||||
await handle_set_widget(new_widget, message, state, bot)
|
||||
48
bot/handlers/commands/settings/settings_cmd.py
Normal file
48
bot/handlers/commands/settings/settings_cmd.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.i18n import gettext as _
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.core.bots import BotInfo
|
||||
from bot.filters import IsOwner
|
||||
from bot.templates import msg
|
||||
from bot.utils import status_clear
|
||||
from configs import COMMANDS
|
||||
|
||||
# Настройки экспорта и роутера
|
||||
__all__ = ("router", "settings_keyboard",)
|
||||
CMD: str = "settings".lower()
|
||||
router: Router = Router(name=f"{CMD}_cmd_router")
|
||||
|
||||
|
||||
def settings_keyboard() -> InlineKeyboardBuilder:
|
||||
"""Клавиатура настроек"""
|
||||
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
|
||||
ikb.row(InlineKeyboardButton(text="🔙 Вернуться", callback_data="settings"))
|
||||
return ikb
|
||||
|
||||
|
||||
@router.callback_query(F.data.lower() == CMD, IsOwner())
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner())
|
||||
async def settings_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
|
||||
"""Обработчик команды /settings"""
|
||||
await status_clear(update=message, state=state)
|
||||
|
||||
# Создание инлайн-клавиатуры
|
||||
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
|
||||
ikb.row(InlineKeyboardButton(text="Имя бота⚜️", callback_data='set_name'))
|
||||
ikb.row(InlineKeyboardButton(text="Описание бота📝", callback_data='set_description'))
|
||||
ikb.row(InlineKeyboardButton(text="Виджет🧩", callback_data='set_widget'))
|
||||
ikb.row(InlineKeyboardButton(text="Назад◀️", callback_data='menu'))
|
||||
|
||||
# Формируем приветственное сообщение
|
||||
text: str = _("""
|
||||
⚙️ Настройки
|
||||
"""
|
||||
).format(
|
||||
)
|
||||
|
||||
# Отправляем сообщение
|
||||
await msg(update=message, text=text, markup=ikb, state=state)
|
||||
33
bot/handlers/commands/users/__init__.py
Normal file
33
bot/handlers/commands/users/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .start_cmd import router as start_cmd_router
|
||||
from .listwords import router as listwords_cmd_router
|
||||
from .word import router as word_cmd_router
|
||||
from .slience import router as slice_router
|
||||
from .conflict import router as conflict_router
|
||||
from .stats import router as stats_router
|
||||
from .report import router as report_router
|
||||
from .admins import router as admin_router
|
||||
from .notifications import router as notifications_router
|
||||
from .id import router as id_router
|
||||
from .emoji import router as emoji_router
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
|
||||
# Подключение роутеров
|
||||
router.include_routers(
|
||||
notifications_router,
|
||||
report_router,
|
||||
admin_router,
|
||||
start_cmd_router,
|
||||
listwords_cmd_router,
|
||||
word_cmd_router,
|
||||
slice_router,
|
||||
conflict_router,
|
||||
stats_router,
|
||||
id_router,
|
||||
emoji_router,
|
||||
)
|
||||
434
bot/handlers/commands/users/admins.py
Normal file
434
bot/handlers/commands/users/admins.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
Обработчики команд управления администраторами
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.filters.admin import IsSuperAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="admin_management_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def parse_user_id(text: str, command: str) -> tuple[bool, str | int]:
|
||||
"""
|
||||
Парсит ID пользователя из команды.
|
||||
|
||||
Args:
|
||||
text: Полный текст сообщения
|
||||
command: Название команды
|
||||
|
||||
Returns:
|
||||
(success, result): result это либо user_id (int), либо текст ошибки (str)
|
||||
"""
|
||||
parts = text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
return False, f"❌ Использование: <code>/{command} <ID></code>"
|
||||
|
||||
user_id_str = parts[1].strip()
|
||||
|
||||
# Валидация ID
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
|
||||
if user_id <= 0:
|
||||
return False, "❌ ID должен быть положительным числом"
|
||||
|
||||
if user_id > 9999999999: # Максимальный Telegram ID
|
||||
return False, "❌ Некорректный ID пользователя"
|
||||
|
||||
return True, user_id
|
||||
|
||||
except ValueError:
|
||||
return False, "❌ ID должен быть числом"
|
||||
|
||||
|
||||
def format_admin_info(user_id: int, username: str | None = None) -> str:
|
||||
"""Форматирует информацию об админе"""
|
||||
if username:
|
||||
return f"<code>{user_id}</code> (@{username})"
|
||||
return f"<code>{user_id}</code>"
|
||||
|
||||
|
||||
def get_refresh_admins_kb():
|
||||
"""Клавиатура для обновления списка админов"""
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh")
|
||||
ikb.button(text="➕ Добавить", callback_data="admin:help_add")
|
||||
ikb.adjust(2)
|
||||
return ikb.as_markup()
|
||||
|
||||
|
||||
# ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addadmin", ["addadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsSuperAdmin())
|
||||
@log_action(action_name="ADD_ADMIN", log_args=True)
|
||||
async def add_admin_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет нового администратора бота.
|
||||
|
||||
Доступно только владельцам бота (OWNER_ID).
|
||||
|
||||
Использование: /addadmin <ID>
|
||||
Пример: /addadmin 123456789
|
||||
"""
|
||||
success, result = parse_user_id(message.text, "addadmin")
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
user_id = result
|
||||
|
||||
# Проверка: нельзя добавить самого себя
|
||||
if user_id == message.from_user.id:
|
||||
await message.answer(
|
||||
"⚠️ <b>Вы уже владелец бота</b>\n\n"
|
||||
"Вам не нужно добавлять себя в администраторы",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Проверка: нельзя добавить другого владельца
|
||||
if user_id in settings.OWNER_ID:
|
||||
await message.answer(
|
||||
"⚠️ <b>Этот пользователь уже владелец бота</b>\n\n"
|
||||
"Владельцы имеют полные права автоматически",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем, уже админ ли
|
||||
is_already_admin = await manager.is_admin(user_id)
|
||||
|
||||
if is_already_admin:
|
||||
await message.answer(
|
||||
f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Добавляем администратора
|
||||
added = await manager.add_admin(
|
||||
user_id=user_id,
|
||||
added_by=message.from_user.id
|
||||
)
|
||||
|
||||
if added:
|
||||
text = (
|
||||
f"✅ <b>Администратор добавлен</b>\n\n"
|
||||
f"👤 ID: {format_admin_info(user_id)}\n"
|
||||
f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
|
||||
f"📋 Права администратора:\n"
|
||||
f"├─ Управление банвордами\n"
|
||||
f"├─ Просмотр статистики\n"
|
||||
f"├─ Активация режимов модерации\n"
|
||||
f"└─ Все команды бота\n\n"
|
||||
f"⚠️ <i>Не может управлять другими админами</i>\n"
|
||||
f"Список админов: /listadmins"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
|
||||
log_type="ADMIN_MGMT"
|
||||
)
|
||||
else:
|
||||
text = "❌ <b>Ошибка добавления администратора</b>\n\nПопробуйте позже"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsSuperAdmin())
|
||||
@log_action(action_name="REMOVE_ADMIN", log_args=True)
|
||||
async def remove_admin_cmd(message: Message) -> None:
|
||||
"""
|
||||
Удаляет администратора бота.
|
||||
|
||||
Доступно только владельцам бота (OWNER_ID).
|
||||
|
||||
Использование: /remadmin <ID>
|
||||
Пример: /remadmin 123456789
|
||||
"""
|
||||
success, result = parse_user_id(message.text, "remadmin")
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
user_id = result
|
||||
|
||||
# Проверка: нельзя удалить владельца
|
||||
if user_id in settings.OWNER_ID:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нельзя удалить владельца</b>\n\n"
|
||||
"Владельцы имеют права постоянно",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Проверка: нельзя удалить самого себя (если вы владелец)
|
||||
if user_id == message.from_user.id:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нельзя удалить самого себя</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем, является ли администратором
|
||||
is_admin = await manager.is_admin(user_id)
|
||||
|
||||
if not is_admin:
|
||||
await message.answer(
|
||||
f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Удаляем администратора
|
||||
removed = await manager.remove_admin(user_id=user_id)
|
||||
|
||||
if removed:
|
||||
text = (
|
||||
f"🗑 <b>Администратор удалён</b>\n\n"
|
||||
f"👤 ID: {format_admin_info(user_id)}\n"
|
||||
f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
|
||||
f"⚠️ <i>Пользователь больше не имеет доступа к командам бота</i>"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
|
||||
log_type="ADMIN_MGMT"
|
||||
)
|
||||
else:
|
||||
text = "❌ <b>Ошибка удаления администратора</b>\n\nПопробуйте позже"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= СПИСОК АДМИНИСТРАТОРОВ =================
|
||||
|
||||
@router.callback_query(F.data == "listadmins:refresh")
|
||||
@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsSuperAdmin())
|
||||
@log_action(action_name="LIST_ADMINS")
|
||||
async def list_admins_cmd(update: Message | CallbackQuery) -> None:
|
||||
"""
|
||||
Показывает список всех администраторов бота.
|
||||
|
||||
Доступно только владельцам бота (OWNER_ID).
|
||||
|
||||
Использование: /listadmins
|
||||
"""
|
||||
# Определяем тип update
|
||||
if isinstance(update, CallbackQuery):
|
||||
message = update.message
|
||||
is_callback = True
|
||||
else:
|
||||
message = update
|
||||
is_callback = False
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Получаем всех админов из БД
|
||||
db_admins = await manager.repo.get_admins()
|
||||
|
||||
# Получаем статистику
|
||||
stats = await manager.get_stats()
|
||||
|
||||
# === ФОРМИРУЕМ ВЫВОД ===
|
||||
|
||||
output = "👥 <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n"
|
||||
|
||||
# Владельцы (OWNER_ID)
|
||||
output += "👑 <b>Владельцы бота</b> (полные права):\n"
|
||||
for owner_id in settings.OWNER_ID:
|
||||
output += f"├─ <code>{owner_id}</code>\n"
|
||||
output += "\n"
|
||||
|
||||
# Администраторы из БД
|
||||
if db_admins:
|
||||
output += f"⚙️ <b>Администраторы</b> ({len(db_admins)}):\n"
|
||||
|
||||
for admin_id in sorted(db_admins):
|
||||
output += f"├─ <code>{admin_id}</code>\n"
|
||||
|
||||
output += "\n"
|
||||
output += "📋 <b>Права администраторов:</b>\n"
|
||||
output += "├─ Управление банвордами\n"
|
||||
output += "├─ Просмотр статистики\n"
|
||||
output += "├─ Активация режимов модерации\n"
|
||||
output += "└─ Все команды бота (кроме управления админами)\n\n"
|
||||
else:
|
||||
output += "⚙️ <b>Администраторы:</b>\n"
|
||||
output += "└─ <i>Нет дополнительных администраторов</i>\n\n"
|
||||
|
||||
# Общая статистика
|
||||
total_admins = len(settings.OWNER_ID) + len(db_admins)
|
||||
output += f"📊 <b>Итого:</b> {total_admins} администратор(ов)\n\n"
|
||||
|
||||
# Команды управления
|
||||
output += "🔧 <b>Управление:</b>\n"
|
||||
output += "• /addadmin <code>ID</code> — добавить админа\n"
|
||||
output += "• /remadmin <code>ID</code> — удалить админа\n\n"
|
||||
|
||||
output += "💡 <i>Только владельцы могут управлять администраторами</i>"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = get_refresh_admins_kb()
|
||||
|
||||
# Отправка
|
||||
if is_callback:
|
||||
await message.edit_text(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await update.answer("✅ Список обновлён")
|
||||
else:
|
||||
await message.answer(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT")
|
||||
|
||||
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
|
||||
|
||||
if is_callback:
|
||||
await update.answer("❌ Ошибка загрузки", show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text, parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK =================
|
||||
|
||||
@router.callback_query(F.data == "admin:help_add")
|
||||
async def admin_help_add_callback(callback: CallbackQuery) -> None:
|
||||
"""Показывает помощь по добавлению админа"""
|
||||
text = (
|
||||
"➕ <b>Как добавить администратора?</b>\n\n"
|
||||
"1️⃣ Узнайте Telegram ID пользователя\n"
|
||||
" • Используйте бота @userinfobot\n"
|
||||
" • Или попросите пользователя написать /start\n\n"
|
||||
"2️⃣ Выполните команду:\n"
|
||||
" <code>/addadmin ID</code>\n\n"
|
||||
"Пример:\n"
|
||||
"<code>/addadmin 123456789</code>"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("adminhelp", ["adminhelp"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsSuperAdmin())
|
||||
async def admin_help_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает подробную справку по управлению администраторами.
|
||||
|
||||
Использование: /adminhelp
|
||||
"""
|
||||
text = (
|
||||
"👥 <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n"
|
||||
"🔐 <b>Уровни доступа:</b>\n\n"
|
||||
"👑 <b>Владельцы</b> (OWNER_ID):\n"
|
||||
"├─ Все права администратора\n"
|
||||
"├─ Управление другими админами\n"
|
||||
"└─ Указываются в конфигурации\n\n"
|
||||
"⚙️ <b>Администраторы:</b>\n"
|
||||
"├─ Управление банвордами\n"
|
||||
"├─ Просмотр статистики\n"
|
||||
"├─ Активация режимов модерации\n"
|
||||
"└─ НЕ могут управлять админами\n\n"
|
||||
"📝 <b>Команды:</b>\n"
|
||||
"• /listadmins — список всех админов\n"
|
||||
"• /addadmin <code>ID</code> — добавить админа\n"
|
||||
"• /remadmin <code>ID</code> — удалить админа\n\n"
|
||||
"💡 <b>Как узнать ID пользователя?</b>\n"
|
||||
"• Используйте бота @userinfobot\n"
|
||||
"• Попросите пользователя написать боту\n"
|
||||
"• ID отображается в логах бота\n\n"
|
||||
"⚠️ <b>Важно:</b>\n"
|
||||
"├─ Нельзя удалить владельца\n"
|
||||
"├─ Нельзя удалить самого себя\n"
|
||||
"└─ Все действия логируются"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("checkadmin", ["checkadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsSuperAdmin())
|
||||
@log_action(action_name="CHECK_ADMIN")
|
||||
async def check_admin_cmd(message: Message) -> None:
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором.
|
||||
|
||||
Использование: /checkadmin <ID>
|
||||
"""
|
||||
success, result = parse_user_id(message.text, "checkadmin")
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
user_id = result
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем статус
|
||||
is_owner = user_id in settings.OWNER_ID
|
||||
is_db_admin = await manager.is_admin(user_id)
|
||||
|
||||
text = f"🔍 <b>Проверка пользователя</b>\n\n"
|
||||
text += f"👤 ID: <code>{user_id}</code>\n\n"
|
||||
|
||||
if is_owner:
|
||||
text += "👑 Статус: <b>Владелец бота</b>\n"
|
||||
text += "✅ Полные права администратора\n"
|
||||
text += "✅ Может управлять админами"
|
||||
elif is_db_admin:
|
||||
text += "⚙️ Статус: <b>Администратор</b>\n"
|
||||
text += "✅ Доступ к командам бота\n"
|
||||
text += "❌ Не может управлять админами"
|
||||
else:
|
||||
text += "👤 Статус: <b>Обычный пользователь</b>\n"
|
||||
text += "❌ Нет прав администратора\n\n"
|
||||
text += f"Добавить в админы: <code>/addadmin {user_id}</code>"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT")
|
||||
await message.answer("❌ <b>Ошибка проверки</b>", parse_mode="HTML")
|
||||
435
bot/handlers/commands/users/conflict.py
Normal file
435
bot/handlers/commands/users/conflict.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
Обработчики команд режима антиконфликта
|
||||
"""
|
||||
from datetime import datetime
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from database.models import BanWordType
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="conflict_mode_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def parse_conflict_args(text: str, command: str, need_minutes: bool = False) -> tuple[bool, str | list]:
|
||||
"""
|
||||
Парсит аргументы команды для конфликтного режима.
|
||||
|
||||
Args:
|
||||
text: Полный текст сообщения
|
||||
command: Название команды
|
||||
need_minutes: Требуется ли параметр минут
|
||||
|
||||
Returns:
|
||||
(success, result): result это либо список аргументов, либо текст ошибки
|
||||
"""
|
||||
parts = text.split(maxsplit=2 if need_minutes else 1)
|
||||
|
||||
min_args = 1 if need_minutes else 1
|
||||
|
||||
if len(parts) < min_args + 1:
|
||||
if need_minutes:
|
||||
return False, f"❌ Использование: <code>/{command} [минуты]</code>"
|
||||
else:
|
||||
return False, f"❌ Использование: <code>/{command} [слово]</code>"
|
||||
|
||||
args = parts[1:]
|
||||
|
||||
# Валидация слова
|
||||
if not need_minutes:
|
||||
if len(args[0]) < 2:
|
||||
return False, "❌ Слово должно содержать минимум 2 символа"
|
||||
|
||||
if len(args[0]) > 100:
|
||||
return False, "❌ Слово слишком длинное (максимум 100 символов)"
|
||||
|
||||
return True, args
|
||||
|
||||
|
||||
def format_time_str(minutes: int) -> str:
|
||||
"""Форматирует время в читабельный формат"""
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
return f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
return f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Форматирует datetime в читабельный формат"""
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
|
||||
# ================= ДОБАВЛЕНИЕ КОНФЛИКТНЫХ СЛОВ =================
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("addconflictword", ["addconflictword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="ADD_CONFLICT_WORD", log_args=True)
|
||||
async def add_conflict_word_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет конфликтное слово-подстроку.
|
||||
|
||||
Конфликтные слова работают только в режиме /stopconflict.
|
||||
|
||||
Использование: /addconflictword <слово>
|
||||
"""
|
||||
success, result = parse_conflict_args(message.text, "addconflictword", need_minutes=False)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.CONFLICT_SUBSTRING,
|
||||
added_by=message.from_user.id,
|
||||
reason="Конфликтное слово"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = (
|
||||
f"✅ <b>Конфликтное слово добавлено</b>\n\n"
|
||||
f"📝 Слово: <code>{word}</code>\n"
|
||||
f"🔍 Тип: подстрока\n\n"
|
||||
f"⚔️ <i>Будет работать только в режиме антиконфликта</i>\n"
|
||||
f"Активируйте: <code>/stopconflict [минуты]</code>"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Конфликтное слово <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления конфликтного слова: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("addconflictlemma", ["addconflictlemma"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="ADD_CONFLICT_LEMMA", log_args=True)
|
||||
async def add_conflict_lemma_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет конфликтную лемму.
|
||||
|
||||
Конфликтные леммы работают только в режиме /stopconflict.
|
||||
|
||||
Использование: /addconflictlemma <слово>
|
||||
"""
|
||||
success, result = parse_conflict_args(message.text, "addconflictlemma", need_minutes=False)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.CONFLICT_LEMMA,
|
||||
added_by=message.from_user.id,
|
||||
reason="Конфликтная лемма"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = (
|
||||
f"✅ <b>Конфликтная лемма добавлена</b>\n\n"
|
||||
f"🔤 Слово: <code>{word}</code>\n"
|
||||
f"🔍 Тип: лемма (все формы слова)\n\n"
|
||||
f"⚔️ <i>Будет работать только в режиме антиконфликта</i>\n"
|
||||
f"Активируйте: <code>/stopconflict [минуты]</code>"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Конфликтная лемма <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления конфликтной леммы: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= УДАЛЕНИЕ КОНФЛИКТНЫХ СЛОВ =================
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("remconflictword", ["remconflictword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="REMOVE_CONFLICT_WORD", log_args=True)
|
||||
async def remove_conflict_word_cmd(message: Message) -> None:
|
||||
"""
|
||||
Удаляет конфликтное слово-подстроку.
|
||||
|
||||
Использование: /remconflictword <слово>
|
||||
"""
|
||||
success, result = parse_conflict_args(message.text, "remconflictword", need_minutes=False)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.CONFLICT_SUBSTRING
|
||||
)
|
||||
|
||||
if removed:
|
||||
text = f"🗑 <b>Конфликтное слово удалено</b>\n\n📝 Слово: <code>{word}</code>"
|
||||
else:
|
||||
text = f"⚠️ Конфликтное слово <code>{word}</code> не найдено"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления конфликтного слова: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("remconflictlemma", ["remconflictlemma"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="REMOVE_CONFLICT_LEMMA", log_args=True)
|
||||
async def remove_conflict_lemma_cmd(message: Message) -> None:
|
||||
"""
|
||||
Удаляет конфликтную лемму.
|
||||
|
||||
Использование: /remconflictlemma <слово>
|
||||
"""
|
||||
success, result = parse_conflict_args(message.text, "remconflictlemma", need_minutes=False)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.CONFLICT_LEMMA
|
||||
)
|
||||
|
||||
if removed:
|
||||
text = f"🗑 <b>Конфликтная лемма удалена</b>\n\n🔤 Слово: <code>{word}</code>"
|
||||
else:
|
||||
text = f"⚠️ Конфликтная лемма <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления конфликтной леммы: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= УПРАВЛЕНИЕ РЕЖИМОМ АНТИКОНФЛИКТА =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("stopconflict", ["stopconflict"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="START_CONFLICT_MODE", log_args=True)
|
||||
async def start_conflict_mode_cmd(message: Message) -> None:
|
||||
"""
|
||||
Активирует режим антиконфликта на указанное время.
|
||||
|
||||
В этом режиме работают только конфликтные слова/леммы.
|
||||
Обычные банворды временно отключаются.
|
||||
|
||||
Использование: /stopconflict <минуты>
|
||||
Пример: /stopconflict 30
|
||||
"""
|
||||
success, result = parse_conflict_args(message.text, "stopconflict", need_minutes=True)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
# Валидация минут
|
||||
try:
|
||||
minutes = int(result[0])
|
||||
if minutes < 1 or minutes > 10080: # Максимум неделя
|
||||
await message.answer(
|
||||
"❌ Время должно быть от 1 минуты до 10080 минут (7 дней)",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Получаем статистику конфликтных слов
|
||||
data = await manager.get_all_words_list()
|
||||
conflict_words_count = len(data.get('conflict_substring', set()))
|
||||
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
||||
total_conflict = conflict_words_count + conflict_lemmas_count
|
||||
|
||||
if total_conflict == 0:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нет конфликтных слов</b>\n\n"
|
||||
"Сначала добавьте конфликтные слова:\n"
|
||||
"• <code>/addconflictword [слово]</code>\n"
|
||||
"• <code>/addconflictlemma [слово]</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Активируем режим
|
||||
expires_at = await manager.set_conflict_mode(minutes)
|
||||
|
||||
time_str = format_time_str(minutes)
|
||||
expires_str = format_datetime(expires_at)
|
||||
|
||||
text = (
|
||||
f"⚔️ <b>РЕЖИМ АНТИКОНФЛИКТА АКТИВИРОВАН</b>\n\n"
|
||||
f"⏱ Длительность: {time_str}\n"
|
||||
f"🕐 Окончание: {expires_str}\n\n"
|
||||
f"📊 Активные правила:\n"
|
||||
f"├─ Конфликтные слова: <code>{conflict_words_count}</code>\n"
|
||||
f"└─ Конфликтные леммы: <code>{conflict_lemmas_count}</code>\n\n"
|
||||
f"⚠️ <i>Обычные банворды временно отключены</i>\n"
|
||||
f"Отключить режим: /unstopconflict"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
f"Режим антиконфликта активирован на {minutes} мин "
|
||||
f"(конфликтных правил: {total_conflict})",
|
||||
log_type="CONFLICT"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации режима антиконфликта: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка активации режима</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("unstopconflict", ["unstopconflict"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="STOP_CONFLICT_MODE")
|
||||
async def stop_conflict_mode_cmd(message: Message) -> None:
|
||||
"""
|
||||
Отключает режим антиконфликта.
|
||||
|
||||
Использование: /unstopconflict
|
||||
"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем, активен ли режим
|
||||
is_active = await manager.is_conflict_active()
|
||||
|
||||
if not is_active:
|
||||
await message.answer(
|
||||
"⚠️ <b>Режим антиконфликта не активен</b>\n\n"
|
||||
"Активируйте: <code>/stopconflict [минуты]</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Отключаем режим
|
||||
await manager.disable_conflict_mode()
|
||||
|
||||
text = (
|
||||
f"✅ <b>Режим антиконфликта отключен</b>\n\n"
|
||||
f"🔄 Обычные банворды снова активны\n"
|
||||
f"⚔️ Конфликтные слова деактивированы"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
logger.info("Режим антиконфликта отключён", log_type="CONFLICT")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения режима антиконфликта: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка отключения режима</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("conflictstatus", ["conflictstatus"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="CONFLICT_STATUS")
|
||||
async def conflict_status_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает статус режима антиконфликта.
|
||||
|
||||
Использование: /conflictstatus
|
||||
"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем активность режима
|
||||
is_active = await manager.is_conflict_active()
|
||||
|
||||
# Получаем статистику
|
||||
data = await manager.get_all_words_list()
|
||||
conflict_words_count = len(data.get('conflict_substring', set()))
|
||||
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
||||
total_conflict = conflict_words_count + conflict_lemmas_count
|
||||
|
||||
if is_active:
|
||||
# Режим активен - показываем детали
|
||||
conflict_until_str = await manager.repo.get_setting("conflict_until")
|
||||
conflict_until = float(conflict_until_str)
|
||||
expires_at = datetime.fromtimestamp(conflict_until)
|
||||
|
||||
now = datetime.now()
|
||||
time_left_seconds = (expires_at - now).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
text = (
|
||||
f"⚔️ <b>РЕЖИМ АНТИКОНФЛИКТА АКТИВЕН</b>\n\n"
|
||||
f"⏱ Осталось: {format_time_str(time_left_minutes)}\n"
|
||||
f"🕐 Окончание: {format_datetime(expires_at)}\n\n"
|
||||
f"📊 Активные правила:\n"
|
||||
f"├─ Конфликтные слова: <code>{conflict_words_count}</code>\n"
|
||||
f"└─ Конфликтные леммы: <code>{conflict_lemmas_count}</code>\n\n"
|
||||
f"⚠️ <i>Обычные банворды отключены</i>\n"
|
||||
f"Отключить: /unstopconflict"
|
||||
)
|
||||
else:
|
||||
# Режим не активен
|
||||
text = (
|
||||
f"💤 <b>Режим антиконфликта НЕ активен</b>\n\n"
|
||||
f"📊 Конфликтных правил в базе:\n"
|
||||
f"├─ Слова: <code>{conflict_words_count}</code>\n"
|
||||
f"└─ Леммы: <code>{conflict_lemmas_count}</code>\n\n"
|
||||
)
|
||||
|
||||
if total_conflict > 0:
|
||||
text += f"Активировать: <code>/stopconflict [минуты]</code>"
|
||||
else:
|
||||
text += (
|
||||
f"⚠️ <i>Нет конфликтных слов</i>\n"
|
||||
f"Добавьте:\n"
|
||||
f"• <code>/addconflictword [слово]</code>\n"
|
||||
f"• <code>/addconflictlemma [слово]</code>"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса режима: {e}", log_type="CONFLICT")
|
||||
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")
|
||||
215
bot/handlers/commands/users/emoji.py
Normal file
215
bot/handlers/commands/users/emoji.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Обработчик команды /emoji для извлечения ID премиум эмодзи
|
||||
"""
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="emoji_extractor_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def extract_custom_emojis(message: Message) -> list[dict]:
|
||||
"""
|
||||
Извлекает все кастомные эмодзи из сообщения.
|
||||
|
||||
Args:
|
||||
message: Сообщение для анализа
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией об эмодзи
|
||||
"""
|
||||
if not message.entities and not message.caption_entities:
|
||||
return []
|
||||
|
||||
# Определяем текст и entities
|
||||
text = message.text or message.caption
|
||||
entities = message.entities or message.caption_entities
|
||||
|
||||
if not text or not entities:
|
||||
return []
|
||||
|
||||
custom_emojis = []
|
||||
|
||||
for entity in entities:
|
||||
if entity.type == "custom_emoji":
|
||||
# Извлекаем символ эмодзи
|
||||
emoji_char = text[entity.offset:entity.offset + entity.length]
|
||||
|
||||
custom_emojis.append({
|
||||
"char": emoji_char,
|
||||
"id": entity.custom_emoji_id,
|
||||
"offset": entity.offset
|
||||
})
|
||||
|
||||
return custom_emojis
|
||||
|
||||
|
||||
def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
|
||||
"""
|
||||
Форматирует эмодзи в HTML-тег.
|
||||
|
||||
Args:
|
||||
emoji_char: Символ эмодзи (fallback)
|
||||
emoji_id: ID кастомного эмодзи
|
||||
|
||||
Returns:
|
||||
HTML-строка
|
||||
"""
|
||||
return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
|
||||
|
||||
|
||||
def escape_html(text: str) -> str:
|
||||
"""Экранирует HTML символы"""
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
# ================= КОМАНДА /EMOJI =================
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("emoji", ["emoji"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin()
|
||||
)
|
||||
async def emoji_extractor_cmd(message: Message) -> None:
|
||||
"""
|
||||
Извлекает кастомные эмодзи из сообщения.
|
||||
|
||||
Доступно только администраторам.
|
||||
|
||||
Использование: /emoji (в ответ на сообщение)
|
||||
"""
|
||||
# Проверяем, что команда в ответ на сообщение
|
||||
if not message.reply_to_message:
|
||||
await message.answer(
|
||||
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
||||
"📝 Как использовать:\n"
|
||||
"1. Ответьте на сообщение с премиум эмодзи\n"
|
||||
"2. Напишите <code>/emoji</code>\n\n"
|
||||
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
replied_message = message.reply_to_message
|
||||
|
||||
# Извлекаем кастомные эмодзи
|
||||
custom_emojis = extract_custom_emojis(replied_message)
|
||||
|
||||
if not custom_emojis:
|
||||
# Нет кастомных эмодзи
|
||||
await message.answer(
|
||||
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
|
||||
"В этом сообщении нет премиум эмодзи.\n\n"
|
||||
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# === ФОРМИРУЕМ ОТВЕТ ===
|
||||
|
||||
output = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}</b>\n\n"
|
||||
|
||||
for idx, emoji_data in enumerate(custom_emojis, 1):
|
||||
emoji_char = emoji_data["char"]
|
||||
emoji_id = emoji_data["id"]
|
||||
|
||||
output += f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
|
||||
output += f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
|
||||
|
||||
# HTML-код (экранированный для отображения)
|
||||
html_code = format_emoji_html(emoji_char, emoji_id)
|
||||
html_escaped = escape_html(html_code)
|
||||
|
||||
output += f"📝 <b>HTML-код:</b>\n"
|
||||
output += f"<code>{html_escaped}</code>\n\n"
|
||||
|
||||
# Пример использования
|
||||
output += f"🎨 <b>Превью:</b> {html_code}\n"
|
||||
|
||||
if idx < len(custom_emojis):
|
||||
output += "\n" + "─" * 30 + "\n\n"
|
||||
|
||||
output += "💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
|
||||
|
||||
# Создаём клавиатуру
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
|
||||
|
||||
# Отправляем
|
||||
try:
|
||||
await message.answer(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=ikb.as_markup()
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}",
|
||||
log_type="EMOJI_EXTRACT"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки эмодзи: {e}", log_type="ERROR")
|
||||
await message.answer(
|
||||
"❌ <b>Ошибка извлечения эмодзи</b>\n\n"
|
||||
"Попробуйте позже или обратитесь к разработчику.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
|
||||
|
||||
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
|
||||
async def emoji_close_callback(callback) -> None:
|
||||
"""Закрывает сообщение с эмодзи"""
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await callback.answer("✅ Закрыто")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сообщения с эмодзи: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Не удалось удалить", show_alert=True)
|
||||
|
||||
|
||||
# ================= ДОПОЛНИТЕЛЬНАЯ КОМАНДА /EMOJIHELP =================
|
||||
|
||||
@router.message(
|
||||
Command(*COMMANDS.get("emojihelp", ["emojihelp"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin()
|
||||
)
|
||||
async def emoji_help_cmd(message: Message) -> None:
|
||||
"""
|
||||
Справка по работе с кастомными эмодзи.
|
||||
"""
|
||||
text = (
|
||||
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
|
||||
"📝 <b>Команда /emoji</b>\n"
|
||||
"Извлекает ID премиум эмодзи из сообщения\n\n"
|
||||
"🔧 <b>Как использовать:</b>\n"
|
||||
"1️⃣ Ответьте на сообщение с эмодзи\n"
|
||||
"2️⃣ Напишите <code>/emoji</code>\n"
|
||||
"3️⃣ Скопируйте HTML-код\n\n"
|
||||
"💻 <b>Формат HTML-кода:</b>\n"
|
||||
"<code><tg-emoji emoji-id=\"ID\">fallback</tg-emoji></code>\n\n"
|
||||
"📌 <b>Пример использования в коде:</b>\n"
|
||||
"<code>text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\n"
|
||||
"await message.answer(text, parse_mode=\"HTML\")</code>\n\n"
|
||||
"⚠️ <b>Важно:</b>\n"
|
||||
"├─ Используйте <code>parse_mode=\"HTML\"</code>\n"
|
||||
"├─ Пользователи без Premium видят fallback\n"
|
||||
"└─ Работает только с кастомными эмодзи\n\n"
|
||||
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
221
bot/handlers/commands/users/id.py
Normal file
221
bot/handlers/commands/users/id.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Обработчик команды /id для получения информации о пользователе
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from configs import settings, COMMANDS
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="user_id_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def get_close_keyboard():
|
||||
"""Создаёт клавиатуру с кнопкой закрытия"""
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="✖️ Закрыть", callback_data="id_close")
|
||||
return ikb.as_markup()
|
||||
|
||||
|
||||
# ================= КОМАНДА /ID =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("id", ["id"]), prefix=settings.PREFIX, ignore_case=True))
|
||||
async def id_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает информацию о вашем Telegram аккаунте.
|
||||
|
||||
Доступно всем пользователям.
|
||||
|
||||
Использование: /id
|
||||
"""
|
||||
user = message.from_user
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Не удалось получить информацию о пользователе")
|
||||
return
|
||||
|
||||
# === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
|
||||
|
||||
output = "👤 <b>ИНФОРМАЦИЯ О ВАС</b>\n\n"
|
||||
|
||||
# Имя
|
||||
full_name_parts = []
|
||||
if user.first_name:
|
||||
full_name_parts.append(user.first_name)
|
||||
if user.last_name:
|
||||
full_name_parts.append(user.last_name)
|
||||
|
||||
full_name = " ".join(full_name_parts) if full_name_parts else "Не указано"
|
||||
output += f"📝 <b>Имя:</b> {full_name}\n"
|
||||
|
||||
# Username
|
||||
if user.username:
|
||||
output += f"🔗 <b>Username:</b> @{user.username}\n"
|
||||
else:
|
||||
output += f"🔗 <b>Username:</b> <i>не установлен</i>\n"
|
||||
|
||||
# ID
|
||||
output += f"🆔 <b>ID:</b> <code>{user.id}</code>\n\n"
|
||||
|
||||
# Тип аккаунта
|
||||
if user.is_bot:
|
||||
output += f"🤖 <b>Тип:</b> Бот\n"
|
||||
elif user.is_premium:
|
||||
output += f"⭐️ <b>Тип:</b> Premium пользователь\n"
|
||||
else:
|
||||
output += f"👥 <b>Тип:</b> Обычный пользователь\n"
|
||||
|
||||
# Дополнительная информация
|
||||
output += "\n📊 <b>Дополнительно:</b>\n"
|
||||
|
||||
# Язык
|
||||
if user.language_code:
|
||||
language_names = {
|
||||
'ru': '🇷🇺 Русский',
|
||||
'en': '🇬🇧 English',
|
||||
'uk': '🇺🇦 Українська',
|
||||
'de': '🇩🇪 Deutsch',
|
||||
'es': '🇪🇸 Español',
|
||||
'fr': '🇫🇷 Français',
|
||||
'it': '🇮🇹 Italiano',
|
||||
'pt': '🇵🇹 Português',
|
||||
}
|
||||
language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}")
|
||||
output += f"├─ Язык: {language}\n"
|
||||
|
||||
# Информация о чате
|
||||
if message.chat.type == "private":
|
||||
output += f"├─ Чат: 💬 Личные сообщения\n"
|
||||
else:
|
||||
chat_title = message.chat.title or "Без названия"
|
||||
chat_types = {
|
||||
"group": "👥 Группа",
|
||||
"supergroup": "👥 Супергруппа",
|
||||
"channel": "📢 Канал"
|
||||
}
|
||||
chat_type = chat_types.get(message.chat.type, "💬 Чат")
|
||||
output += f"├─ Чат: {chat_type}\n"
|
||||
output += f"├─ Название: {chat_title}\n"
|
||||
output += f"├─ Chat ID: <code>{message.chat.id}</code>\n"
|
||||
|
||||
# Получаем количество участников (только для групп)
|
||||
try:
|
||||
member_count = await message.bot.get_chat_member_count(message.chat.id)
|
||||
output += f"├─ Участников: {member_count}\n"
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
|
||||
|
||||
# Message ID
|
||||
output += f"└─ Message ID: <code>{message.message_id}</code>\n\n"
|
||||
|
||||
# Подсказка
|
||||
output += "💡 <i>Эту информацию видите только вы</i>"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = get_close_keyboard()
|
||||
|
||||
# Отправляем
|
||||
try:
|
||||
await message.answer(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
logger.debug(f"Команда /id от пользователя {user.id}", log_type="USER_ID")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR")
|
||||
await message.answer("❌ Произошла ошибка при получении информации")
|
||||
|
||||
|
||||
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
|
||||
|
||||
@router.callback_query(F.data == "id_close")
|
||||
async def id_close_callback(callback: CallbackQuery) -> None:
|
||||
"""Закрывает (удаляет) сообщение с информацией"""
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await callback.answer("✅ Закрыто")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Не удалось удалить сообщение", show_alert=True)
|
||||
|
||||
|
||||
# ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("myid", ["myid"]), prefix=settings.PREFIX, ignore_case=True))
|
||||
async def myid_cmd(message: Message) -> None:
|
||||
"""
|
||||
Быстрый просмотр вашего ID.
|
||||
|
||||
Использование: /myid
|
||||
"""
|
||||
user = message.from_user
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Не удалось получить ID")
|
||||
return
|
||||
|
||||
# Короткий ответ
|
||||
text = f"🆔 Ваш ID: <code>{user.id}</code>"
|
||||
|
||||
if user.username:
|
||||
text += f"\n🔗 Username: @{user.username}"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= КОМАНДА /CHATID =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("chatid", ["chatid"]), prefix=settings.PREFIX, ignore_case=True))
|
||||
async def chatid_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает ID текущего чата.
|
||||
|
||||
Использование: /chatid
|
||||
"""
|
||||
chat = message.chat
|
||||
|
||||
output = "💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n"
|
||||
|
||||
# Тип чата
|
||||
chat_types = {
|
||||
"private": "💬 Личные сообщения",
|
||||
"group": "👥 Группа",
|
||||
"supergroup": "👥 Супергруппа",
|
||||
"channel": "📢 Канал"
|
||||
}
|
||||
chat_type = chat_types.get(chat.type, "💬 Чат")
|
||||
|
||||
output += f"📝 <b>Тип:</b> {chat_type}\n"
|
||||
|
||||
if chat.title:
|
||||
output += f"📌 <b>Название:</b> {chat.title}\n"
|
||||
|
||||
if chat.username:
|
||||
output += f"🔗 <b>Username:</b> @{chat.username}\n"
|
||||
|
||||
output += f"🆔 <b>Chat ID:</b> <code>{chat.id}</code>\n"
|
||||
|
||||
# Дополнительная информация для групп
|
||||
if chat.type in ["group", "supergroup"]:
|
||||
try:
|
||||
member_count = await message.bot.get_chat_member_count(chat.id)
|
||||
output += f"👥 <b>Участников:</b> {member_count}\n"
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
|
||||
|
||||
keyboard = get_close_keyboard()
|
||||
|
||||
await message.answer(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
238
bot/handlers/commands/users/listwords.py
Normal file
238
bot/handlers/commands/users/listwords.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Обработчик команды /listwords - отображение всех правил модерации
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
CMD: str = "list"
|
||||
router: Router = Router(name="listwords_cmd_router")
|
||||
|
||||
|
||||
def get_refresh_kb(page: int = 0):
|
||||
"""Клавиатура с кнопкой обновления"""
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="🔄 Обновить", callback_data=f"listwords:refresh:{page}")
|
||||
ikb.button(text="📊 Статистика", callback_data="stats")
|
||||
ikb.adjust(2)
|
||||
return ikb.as_markup()
|
||||
|
||||
|
||||
async def format_banwords_list(page: int = 0) -> str:
|
||||
"""
|
||||
Форматирует список всех банвордов с разбивкой по типам.
|
||||
|
||||
Args:
|
||||
page: Номер страницы (для будущей пагинации)
|
||||
|
||||
Returns:
|
||||
Отформатированная строка со всеми правилами
|
||||
"""
|
||||
manager = get_manager()
|
||||
|
||||
# Получаем все данные из БД
|
||||
try:
|
||||
# Используем существующий метод get_all_words_list()
|
||||
data = await manager.get_all_words_list()
|
||||
stats = await manager.get_stats()
|
||||
|
||||
# Извлекаем данные из словаря
|
||||
permanent_words = list(data.get('substring', set()))
|
||||
permanent_lemmas = list(data.get('lemma', set()))
|
||||
permanent_parts = list(data.get('part', set()))
|
||||
temp_words = list(data.get('temp_substring', set()))
|
||||
temp_lemmas = list(data.get('temp_lemma', set()))
|
||||
conflict_words = list(data.get('conflict_substring', set()))
|
||||
conflict_lemmas = list(data.get('conflict_lemma', set()))
|
||||
exceptions = list(data.get('whitelist', set()))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных из БД: {e}", log_type="LISTWORDS")
|
||||
return "❌ <b>Ошибка загрузки данных из базы</b>"
|
||||
|
||||
# === ФОРМИРУЕМ ВЫВОД ===
|
||||
|
||||
output = "📋 <b>СПИСОК ПРАВИЛ МОДЕРАЦИИ</b>\n\n"
|
||||
|
||||
# Статистика
|
||||
total_count = (
|
||||
len(permanent_words) + len(permanent_lemmas) + len(permanent_parts) +
|
||||
len(temp_words) + len(temp_lemmas) +
|
||||
len(conflict_words) + len(conflict_lemmas)
|
||||
)
|
||||
|
||||
output += f"📊 <b>Общая статистика:</b>\n"
|
||||
output += f"├─ Всего правил: <code>{total_count}</code>\n"
|
||||
output += f"├─ Исключений: <code>{len(exceptions)}</code>\n"
|
||||
output += f"├─ Удалений за всё время: <code>{stats.get('total_deletions', 0)}</code>\n"
|
||||
output += f"└─ Администраторов: <code>{stats.get('admins', 0)}</code>\n\n"
|
||||
|
||||
# === ПОСТОЯННЫЕ ПРАВИЛА ===
|
||||
if permanent_words or permanent_lemmas or permanent_parts:
|
||||
output += "🔴 <b>ПОСТОЯННЫЕ ПРАВИЛА:</b>\n\n"
|
||||
|
||||
if permanent_words:
|
||||
output += f"📝 <b>Подстроки</b> ({len(permanent_words)}):\n"
|
||||
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_words)[:20]])
|
||||
if len(permanent_words) > 20:
|
||||
words_str += f" ... <i>(+{len(permanent_words) - 20} ещё)</i>"
|
||||
output += f"{words_str}\n\n"
|
||||
|
||||
if permanent_lemmas:
|
||||
output += f"🔤 <b>Леммы</b> ({len(permanent_lemmas)}):\n"
|
||||
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_lemmas)[:20]])
|
||||
if len(permanent_lemmas) > 20:
|
||||
lemmas_str += f" ... <i>(+{len(permanent_lemmas) - 20} ещё)</i>"
|
||||
output += f"{lemmas_str}\n\n"
|
||||
|
||||
if permanent_parts:
|
||||
output += f"🧩 <b>Части</b> ({len(permanent_parts)}):\n"
|
||||
parts_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_parts)[:20]])
|
||||
if len(permanent_parts) > 20:
|
||||
parts_str += f" ... <i>(+{len(permanent_parts) - 20} ещё)</i>"
|
||||
output += f"{parts_str}\n\n"
|
||||
|
||||
# === ВРЕМЕННЫЕ ПРАВИЛА ===
|
||||
if temp_words or temp_lemmas:
|
||||
output += "⏱ <b>ВРЕМЕННЫЕ ПРАВИЛА:</b>\n\n"
|
||||
|
||||
if temp_words:
|
||||
output += f"📝 <b>Временные подстроки</b> ({len(temp_words)}):\n"
|
||||
# Для временных слов нужна дополнительная информация о времени истечения
|
||||
# Пока просто выводим список
|
||||
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_words)[:15]])
|
||||
if len(temp_words) > 15:
|
||||
words_str += f" ... <i>(+{len(temp_words) - 15} ещё)</i>"
|
||||
output += f"{words_str}\n\n"
|
||||
|
||||
if temp_lemmas:
|
||||
output += f"🔤 <b>Временные леммы</b> ({len(temp_lemmas)}):\n"
|
||||
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_lemmas)[:15]])
|
||||
if len(temp_lemmas) > 15:
|
||||
lemmas_str += f" ... <i>(+{len(temp_lemmas) - 15} ещё)</i>"
|
||||
output += f"{lemmas_str}\n\n"
|
||||
|
||||
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
|
||||
if conflict_words or conflict_lemmas:
|
||||
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
|
||||
output += "<i>(работают только в режиме /stopconflict)</i>\n\n"
|
||||
|
||||
if conflict_words:
|
||||
output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n"
|
||||
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(conflict_words)[:15]])
|
||||
if len(conflict_words) > 15:
|
||||
words_str += f" ... <i>(+{len(conflict_words) - 15} ещё)</i>"
|
||||
output += f"{words_str}\n\n"
|
||||
|
||||
if conflict_lemmas:
|
||||
output += f"🔤 <b>Конфликтные леммы</b> ({len(conflict_lemmas)}):\n"
|
||||
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(conflict_lemmas)[:15]])
|
||||
if len(conflict_lemmas) > 15:
|
||||
lemmas_str += f" ... <i>(+{len(conflict_lemmas) - 15} ещё)</i>"
|
||||
output += f"{lemmas_str}\n\n"
|
||||
|
||||
# === ИСКЛЮЧЕНИЯ (WHITELIST) ===
|
||||
if exceptions:
|
||||
output += f"✅ <b>ИСКЛЮЧЕНИЯ</b> ({len(exceptions)}):\n"
|
||||
exc_str = ', '.join([f"<code>{exceptions}</code>" for w in sorted(exceptions)[:15]])
|
||||
if len(exceptions) > 15:
|
||||
exc_str += f" ... <i>(+{len(exceptions) - 15} ещё)</i>"
|
||||
output += f"{exc_str}\n\n"
|
||||
|
||||
# === АКТИВНЫЕ РЕЖИМЫ ===
|
||||
active_modes = []
|
||||
|
||||
if await manager.is_silence_active():
|
||||
active_modes.append("🔇 Режим тишины")
|
||||
|
||||
if await manager.is_conflict_active():
|
||||
active_modes.append("⚔️ Режим антиконфликта")
|
||||
|
||||
if active_modes:
|
||||
output += "🔴 <b>АКТИВНЫЕ РЕЖИМЫ:</b>\n"
|
||||
for mode in active_modes:
|
||||
output += f"{mode}\n"
|
||||
output += "\n"
|
||||
|
||||
# === ПУСТОЙ СПИСОК ===
|
||||
if total_count == 0:
|
||||
output = (
|
||||
"📋 <b>СПИСОК ПРАВИЛ МОДЕРАЦИИ</b>\n\n"
|
||||
"⚠️ <i>Правила модерации не настроены</i>\n\n"
|
||||
"Используйте команды добавления:\n"
|
||||
"• /addword — добавить подстроку\n"
|
||||
"• /addlemma — добавить лемму\n"
|
||||
"• /addpart — добавить часть\n\n"
|
||||
"📖 Подробнее: /start"
|
||||
)
|
||||
|
||||
# Ограничение длины (Telegram limit 4096)
|
||||
if len(output) > 4000:
|
||||
output = output[:3950] + "\n\n<i>... список обрезан, слишком много правил</i>"
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("listwords:refresh"))
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="LISTWORDS_COMMAND")
|
||||
async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
||||
"""
|
||||
Обработчик команды /listwords.
|
||||
Отображает список всех правил модерации с разбивкой по категориям.
|
||||
|
||||
Доступно только администраторам.
|
||||
|
||||
Args:
|
||||
update: Message или CallbackQuery
|
||||
"""
|
||||
# Определяем тип update
|
||||
if isinstance(update, CallbackQuery):
|
||||
message = update.message
|
||||
is_callback = True
|
||||
# Извлекаем номер страницы из callback_data
|
||||
try:
|
||||
page = int(update.data.split(":")[-1])
|
||||
except:
|
||||
page = 0
|
||||
else:
|
||||
message = update
|
||||
is_callback = False
|
||||
page = 0
|
||||
|
||||
# Формируем список
|
||||
try:
|
||||
text = await format_banwords_list(page)
|
||||
keyboard = get_refresh_kb(page)
|
||||
|
||||
if is_callback:
|
||||
await message.edit_text(
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await update.answer("✅ Список обновлён")
|
||||
else:
|
||||
await message.answer(
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки списка банвордов: {e}", log_type="LISTWORDS")
|
||||
|
||||
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
|
||||
|
||||
if is_callback:
|
||||
await update.answer("❌ Ошибка загрузки", show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text, parse_mode="HTML")
|
||||
118
bot/handlers/commands/users/notifications.py
Normal file
118
bot/handlers/commands/users/notifications.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Обработчики callback-кнопок уведомлений о спаме
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="spam_notifications_router")
|
||||
|
||||
|
||||
# ================= ЗАКРЫТИЕ УВЕДОМЛЕНИЯ =================
|
||||
|
||||
@router.callback_query(F.data == "spam_close", IsAdmin())
|
||||
async def spam_close_callback(callback: CallbackQuery) -> None:
|
||||
"""
|
||||
Закрывает (удаляет) уведомление о спаме.
|
||||
"""
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await callback.answer("✅ Уведомление закрыто")
|
||||
|
||||
logger.debug(
|
||||
f"Уведомление о спаме закрыто админом {callback.from_user.id}",
|
||||
log_type="SPAM_NOTIFICATION"
|
||||
)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"Ошибка удаления уведомления: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Не удалось удалить уведомление", show_alert=True)
|
||||
|
||||
|
||||
# ================= БАН ПОЛЬЗОВАТЕЛЯ =================
|
||||
|
||||
@router.callback_query(F.data.startswith("spam_ban:"), IsAdmin())
|
||||
async def spam_ban_callback(callback: CallbackQuery) -> None:
|
||||
"""
|
||||
Банит пользователя прямо из уведомления.
|
||||
"""
|
||||
try:
|
||||
# Парсим данные: spam_ban:user_id:chat_id
|
||||
parts = callback.data.split(":")
|
||||
user_id = int(parts[1])
|
||||
chat_id = int(parts[2])
|
||||
|
||||
# Баним пользователя
|
||||
try:
|
||||
await callback.bot.ban_chat_member(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Обновляем сообщение
|
||||
updated_text = callback.message.text + f"\n\n🔨 <b>Пользователь забанен</b> (@{callback.from_user.username or callback.from_user.id})"
|
||||
|
||||
# Убираем кнопки
|
||||
await callback.message.edit_text(
|
||||
text=updated_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Пользователь забанен", show_alert=True)
|
||||
|
||||
logger.info(
|
||||
f"Пользователь {user_id} забанен админом {callback.from_user.id} через уведомление о спаме",
|
||||
log_type="SPAM_BAN"
|
||||
)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
await callback.answer(f"❌ Ошибка бана: {str(e)}", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки бана из уведомления: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
||||
|
||||
|
||||
# ================= СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ =================
|
||||
|
||||
@router.callback_query(F.data.startswith("spam_stats:"), IsAdmin())
|
||||
async def spam_stats_callback(callback: CallbackQuery) -> None:
|
||||
"""
|
||||
Показывает статистику пользователя.
|
||||
"""
|
||||
try:
|
||||
# Парсим данные: spam_stats:user_id
|
||||
parts = callback.data.split(":")
|
||||
user_id = int(parts[1])
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
# Получаем статистику
|
||||
spam_count = await manager.get_user_spam_count(user_id)
|
||||
recent_spam = await manager.get_spam_stats(limit=5, user_id=user_id)
|
||||
|
||||
# Формируем текст
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"🆔 ID: <code>{user_id}</code>\n"
|
||||
text += f"🗑 Удалено сообщений: <code>{spam_count}</code>\n\n"
|
||||
|
||||
if recent_spam:
|
||||
text += f"📝 <b>Последние нарушения:</b>\n"
|
||||
for idx, stat in enumerate(recent_spam, 1):
|
||||
matched_word = stat.matched_word or "неизвестно"
|
||||
match_type = stat.match_type or "unknown"
|
||||
text += f"{idx}. <code>{matched_word}</code> ({match_type})\n"
|
||||
else:
|
||||
text += "✅ <i>Нет нарушений</i>"
|
||||
|
||||
await callback.answer(text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики из уведомления: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Ошибка получения статистики", show_alert=True)
|
||||
447
bot/handlers/commands/users/report.py
Normal file
447
bot/handlers/commands/users/report.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
Обработчики команды /report для пользователей
|
||||
"""
|
||||
from datetime import datetime
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery, User
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="report_router")
|
||||
|
||||
|
||||
# ================= НАСТРОЙКИ =================
|
||||
|
||||
# ID чата для отправки репортов (можно вынести в configs)
|
||||
# Если None, репорты отправляются всем владельцам в ЛС
|
||||
REPORT_CHAT_ID = getattr(settings, 'REPORT_CHAT_ID', None)
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def format_user(user: User) -> str:
|
||||
"""
|
||||
Форматирует информацию о пользователе.
|
||||
|
||||
Args:
|
||||
user: Объект User
|
||||
|
||||
Returns:
|
||||
Отформатированная строка с именем и username
|
||||
"""
|
||||
if not user:
|
||||
return "Unknown User"
|
||||
|
||||
# Формируем имя
|
||||
name_parts = []
|
||||
if user.first_name:
|
||||
name_parts.append(user.first_name)
|
||||
if user.last_name:
|
||||
name_parts.append(user.last_name)
|
||||
|
||||
full_name = " ".join(name_parts) if name_parts else "No Name"
|
||||
|
||||
# Добавляем username если есть
|
||||
if user.username:
|
||||
return f"{full_name} (@{user.username})"
|
||||
else:
|
||||
return full_name
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Форматирует datetime"""
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 200) -> str:
|
||||
"""Обрезает текст до указанной длины"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length] + "..."
|
||||
|
||||
|
||||
def get_report_keyboard(
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
reported_user_id: int,
|
||||
report_id: str
|
||||
) -> InlineKeyboardBuilder:
|
||||
"""
|
||||
Создает клавиатуру для репорта.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата, где было сообщение
|
||||
message_id: ID сообщения
|
||||
reported_user_id: ID пользователя, на которого пожаловались
|
||||
report_id: Уникальный ID репорта
|
||||
"""
|
||||
ikb = InlineKeyboardBuilder()
|
||||
|
||||
# Кнопки действий
|
||||
ikb.button(
|
||||
text="🚫 Забанить",
|
||||
callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}"
|
||||
)
|
||||
ikb.button(
|
||||
text="🗑 Удалить",
|
||||
callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}"
|
||||
)
|
||||
ikb.button(
|
||||
text="✅ Закрыть",
|
||||
callback_data=f"report:close:{report_id}"
|
||||
)
|
||||
|
||||
ikb.adjust(2, 1)
|
||||
return ikb
|
||||
|
||||
|
||||
def generate_report_id() -> str:
|
||||
"""Генерирует уникальный ID репорта"""
|
||||
return f"{int(datetime.now().timestamp() * 1000)}"
|
||||
|
||||
|
||||
# ================= КОМАНДА РЕПОРТА =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("report", ["report"]), prefix=settings.PREFIX, ignore_case=True))
|
||||
async def report_cmd(message: Message) -> None:
|
||||
"""
|
||||
Отправляет жалобу на сообщение администраторам.
|
||||
|
||||
Доступно всем пользователям.
|
||||
|
||||
Использование:
|
||||
/report — в ответ на сообщение
|
||||
/report <причина> — в ответ на сообщение с указанием причины
|
||||
|
||||
Пример:
|
||||
/report спам
|
||||
/report оскорбления
|
||||
"""
|
||||
# Проверяем, что команда в ответ на сообщение
|
||||
if not message.reply_to_message:
|
||||
await message.answer(
|
||||
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
||||
"Как использовать:\n"
|
||||
"1. Ответьте на сообщение нарушителя\n"
|
||||
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
|
||||
"Пример: <code>/report спам</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
reported_message = message.reply_to_message
|
||||
reported_user = reported_message.from_user
|
||||
reporter = message.from_user
|
||||
|
||||
# Проверка на None
|
||||
if not reported_user or not reporter:
|
||||
await message.answer("❌ <b>Ошибка получения данных пользователя</b>", parse_mode="HTML")
|
||||
return
|
||||
|
||||
# Нельзя пожаловаться на самого себя
|
||||
if reported_user.id == reporter.id:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нельзя пожаловаться на самого себя</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Нельзя пожаловаться на бота
|
||||
if reported_user.is_bot:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нельзя пожаловаться на бота</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Нельзя пожаловаться на администратора
|
||||
manager = get_manager()
|
||||
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
|
||||
|
||||
if is_admin:
|
||||
await message.answer(
|
||||
"⚠️ <b>Нельзя пожаловаться на администратора</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Извлекаем причину (опционально)
|
||||
parts = message.text.split(maxsplit=1)
|
||||
reason = parts[1] if len(parts) > 1 else "Не указана"
|
||||
|
||||
# Генерируем ID репорта
|
||||
report_id = generate_report_id()
|
||||
|
||||
# === ФОРМИРУЕМ СООБЩЕНИЕ РЕПОРТА ===
|
||||
|
||||
report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
|
||||
|
||||
# Информация о жалобщике
|
||||
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
|
||||
|
||||
# Информация о нарушителе
|
||||
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
|
||||
|
||||
# Информация о чате
|
||||
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
|
||||
report_text += f"💬 <b>Чат:</b> {chat_title}\n"
|
||||
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n\n"
|
||||
|
||||
# Причина
|
||||
report_text += f"📝 <b>Причина:</b> {reason}\n\n"
|
||||
|
||||
# Текст сообщения
|
||||
report_text += f"📄 <b>Текст сообщения:</b>\n"
|
||||
|
||||
if reported_message.text:
|
||||
truncated_text = truncate_text(reported_message.text, max_length=300)
|
||||
report_text += f"<code>{truncated_text}</code>\n\n"
|
||||
elif reported_message.caption:
|
||||
truncated_caption = truncate_text(reported_message.caption, max_length=300)
|
||||
report_text += f"<code>{truncated_caption}</code>\n\n"
|
||||
else:
|
||||
content_type = reported_message.content_type
|
||||
report_text += f"<i>[{content_type}]</i>\n\n"
|
||||
|
||||
# Время
|
||||
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
|
||||
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
|
||||
|
||||
report_text += f"💡 <i>ID репорта: {report_id}</i>"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = get_report_keyboard(
|
||||
chat_id=message.chat.id,
|
||||
message_id=reported_message.message_id,
|
||||
reported_user_id=reported_user.id,
|
||||
report_id=report_id
|
||||
)
|
||||
|
||||
# === ОТПРАВКА РЕПОРТА ===
|
||||
|
||||
try:
|
||||
# Если указан админ-чат, отправляем туда
|
||||
if REPORT_CHAT_ID:
|
||||
await message.bot.send_message(
|
||||
chat_id=REPORT_CHAT_ID,
|
||||
text=report_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard.as_markup()
|
||||
)
|
||||
else:
|
||||
# Отправляем всем владельцам
|
||||
sent_count = 0
|
||||
for owner_id in settings.OWNER_ID:
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=owner_id,
|
||||
text=report_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard.as_markup()
|
||||
)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки репорта владельцу {owner_id}: {e}", log_type="REPORT")
|
||||
|
||||
if sent_count == 0:
|
||||
raise Exception("Не удалось отправить репорт ни одному владельцу")
|
||||
|
||||
# Подтверждение пользователю
|
||||
await message.answer(
|
||||
"✅ <b>Жалоба отправлена администраторам</b>\n\n"
|
||||
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Логирование
|
||||
logger.info(
|
||||
f"Репорт #{report_id}: {reporter.id} → {reported_user.id} в чате {message.chat.id}",
|
||||
log_type="REPORT"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
|
||||
await message.answer(
|
||||
"❌ <b>Ошибка отправки жалобы</b>\n\nПопробуйте позже или обратитесь к администратору напрямую.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
# ================= ОБРАБОТЧИКИ КНОПОК =================
|
||||
|
||||
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
|
||||
async def report_ban_callback(callback: CallbackQuery) -> None:
|
||||
"""Обрабатывает нажатие кнопки 'Забанить'"""
|
||||
try:
|
||||
# Парсим данные: report:ban:chat_id:user_id:report_id
|
||||
parts = callback.data.split(":")
|
||||
chat_id = int(parts[2])
|
||||
user_id = int(parts[3])
|
||||
report_id = parts[4]
|
||||
|
||||
# Баним пользователя
|
||||
try:
|
||||
await callback.bot.ban_chat_member(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
admin_name = format_user(callback.from_user)
|
||||
|
||||
# Обновляем сообщение
|
||||
updated_text = callback.message.text + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})"
|
||||
|
||||
# Убираем кнопки
|
||||
await callback.message.edit_text(
|
||||
text=updated_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Пользователь забанен", show_alert=True)
|
||||
|
||||
logger.info(
|
||||
f"Репорт #{report_id}: пользователь {user_id} забанен админом {callback.from_user.id}",
|
||||
log_type="REPORT"
|
||||
)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
await callback.answer(f"❌ Ошибка бана: {str(e)}", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки бана из репорта: {e}", log_type="REPORT")
|
||||
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("report:delete:"), IsAdmin())
|
||||
async def report_delete_callback(callback: CallbackQuery) -> None:
|
||||
"""Обрабатывает нажатие кнопки 'Удалить'"""
|
||||
try:
|
||||
# Парсим данные: report:delete:chat_id:message_id:report_id
|
||||
parts = callback.data.split(":")
|
||||
chat_id = int(parts[2])
|
||||
message_id = int(parts[3])
|
||||
report_id = parts[4]
|
||||
|
||||
# Удаляем сообщение
|
||||
try:
|
||||
await callback.bot.delete_message(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id
|
||||
)
|
||||
|
||||
admin_name = format_user(callback.from_user)
|
||||
|
||||
# Обновляем сообщение
|
||||
updated_text = callback.message.text + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})"
|
||||
|
||||
# Убираем кнопки
|
||||
await callback.message.edit_text(
|
||||
text=updated_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Сообщение удалено", show_alert=True)
|
||||
|
||||
logger.info(
|
||||
f"Репорт #{report_id}: сообщение {message_id} удалено админом {callback.from_user.id}",
|
||||
log_type="REPORT"
|
||||
)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
await callback.answer(f"❌ Ошибка удаления: {str(e)}", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из репорта: {e}", log_type="REPORT")
|
||||
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("report:close:"), IsAdmin())
|
||||
async def report_close_callback(callback: CallbackQuery) -> None:
|
||||
"""Обрабатывает нажатие кнопки 'Закрыть'"""
|
||||
try:
|
||||
# Парсим данные: report:close:report_id
|
||||
parts = callback.data.split(":")
|
||||
report_id = parts[2]
|
||||
|
||||
admin_name = format_user(callback.from_user)
|
||||
|
||||
# Обновляем сообщение
|
||||
updated_text = callback.message.text + f"\n\n✅ <b>Репорт закрыт</b> ({admin_name})"
|
||||
|
||||
# Убираем кнопки
|
||||
await callback.message.edit_text(
|
||||
text=updated_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Репорт закрыт")
|
||||
|
||||
logger.info(
|
||||
f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
|
||||
log_type="REPORT"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия репорта: {e}", log_type="REPORT")
|
||||
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
||||
|
||||
|
||||
# ================= ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
|
||||
async def report_help_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает справку по системе репортов.
|
||||
|
||||
Доступно всем пользователям.
|
||||
"""
|
||||
text = (
|
||||
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n"
|
||||
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
|
||||
"📝 <b>Как пожаловаться:</b>\n"
|
||||
"1. Ответьте на сообщение нарушителя\n"
|
||||
"2. Напишите <code>/report</code>\n"
|
||||
"3. Можно указать причину: <code>/report спам</code>\n\n"
|
||||
"✅ <b>Примеры:</b>\n"
|
||||
"• <code>/report</code> — жалоба без причины\n"
|
||||
"• <code>/report спам</code> — жалоба на спам\n"
|
||||
"• <code>/report оскорбления</code> — жалоба на оскорбления\n\n"
|
||||
"⚠️ <b>Важно:</b>\n"
|
||||
"├─ Нельзя пожаловаться на себя\n"
|
||||
"├─ Нельзя пожаловаться на ботов\n"
|
||||
"├─ Нельзя пожаловаться на администраторов\n"
|
||||
"└─ Ложные жалобы могут привести к бану\n\n"
|
||||
"💡 <i>Администраторы получат уведомление и примут меры</i>"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
async def report_stats_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает статистику по репортам (для админов).
|
||||
|
||||
TODO: Реализовать сохранение статистики в БД
|
||||
"""
|
||||
text = (
|
||||
"📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
|
||||
"⚠️ <i>Функция в разработке</i>\n\n"
|
||||
"Планируется:\n"
|
||||
"• Всего репортов за всё время\n"
|
||||
"• Топ жалобщиков\n"
|
||||
"• Топ нарушителей\n"
|
||||
"• Распределение по причинам\n"
|
||||
"• Статистика обработки\n\n"
|
||||
"💡 <i>Для реализации нужно добавить таблицу reports в БД</i>"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
346
bot/handlers/commands/users/slience.py
Normal file
346
bot/handlers/commands/users/slience.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Обработчики команд режима тишины
|
||||
"""
|
||||
from datetime import datetime
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="silence_mode_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def parse_silence_args(text: str) -> tuple[bool, str | int]:
|
||||
"""
|
||||
Парсит аргументы команды для режима тишины.
|
||||
|
||||
Args:
|
||||
text: Полный текст сообщения
|
||||
|
||||
Returns:
|
||||
(success, result): result это либо минуты (int), либо текст ошибки (str)
|
||||
"""
|
||||
parts = text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
return False, "❌ Использование: <code>/silence <минуты></code>"
|
||||
|
||||
return True, parts[1]
|
||||
|
||||
|
||||
def format_time_str(minutes: int) -> str:
|
||||
"""Форматирует время в читабельный формат"""
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
return f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
return f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Форматирует datetime в читабельный формат"""
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
|
||||
# ================= КОМАНДЫ РЕЖИМА ТИШИНЫ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("silence", ["silence"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="START_SILENCE_MODE", log_args=True)
|
||||
async def start_silence_mode_cmd(message: Message) -> None:
|
||||
"""
|
||||
Активирует режим тишины на указанное время.
|
||||
|
||||
В этом режиме удаляются ВСЕ сообщения от обычных пользователей.
|
||||
Администраторы могут продолжать писать.
|
||||
|
||||
Использование: /silence <минуты>
|
||||
Примеры:
|
||||
/silence 30 — на 30 минут
|
||||
/silence 120 — на 2 часа
|
||||
/silence 1440 — на сутки
|
||||
"""
|
||||
success, result = parse_silence_args(message.text)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
# Валидация минут
|
||||
try:
|
||||
minutes = int(result)
|
||||
if minutes < 1 or minutes > 10080: # Максимум неделя
|
||||
await message.answer(
|
||||
"❌ Время должно быть от 1 минуты до 10080 минут (7 дней)",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем, уже активен ли режим
|
||||
is_already_active = await manager.is_silence_active()
|
||||
|
||||
# Активируем режим (перезаписывает предыдущий, если был)
|
||||
expires_at = await manager.set_silence_mode(minutes)
|
||||
|
||||
time_str = format_time_str(minutes)
|
||||
expires_str = format_datetime(expires_at)
|
||||
|
||||
if is_already_active:
|
||||
action_text = "🔄 <b>РЕЖИМ ТИШИНЫ ОБНОВЛЁН</b>"
|
||||
else:
|
||||
action_text = "🔇 <b>РЕЖИМ ТИШИНЫ АКТИВИРОВАН</b>"
|
||||
|
||||
text = (
|
||||
f"{action_text}\n\n"
|
||||
f"⏱ Длительность: {time_str}\n"
|
||||
f"🕐 Окончание: {expires_str}\n\n"
|
||||
f"⚠️ <b>Что происходит:</b>\n"
|
||||
f"├─ Все сообщения от пользователей удаляются\n"
|
||||
f"├─ Администраторы могут писать\n"
|
||||
f"└─ Банворды временно отключены\n\n"
|
||||
f"💡 <i>Используйте для успокоения спора или флуда</i>\n"
|
||||
f"Отключить досрочно: /unsilence"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
f"Режим тишины {'обновлён' if is_already_active else 'активирован'} на {minutes} мин "
|
||||
f"пользователем {message.from_user.id}",
|
||||
log_type="SILENCE"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации режима тишины: {e}", log_type="SILENCE")
|
||||
await message.answer("❌ <b>Ошибка активации режима</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("unsilence", ["unsilence"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="STOP_SILENCE_MODE")
|
||||
async def stop_silence_mode_cmd(message: Message) -> None:
|
||||
"""
|
||||
Отключает режим тишины.
|
||||
|
||||
Использование: /unsilence
|
||||
"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем, активен ли режим
|
||||
is_active = await manager.is_silence_active()
|
||||
|
||||
if not is_active:
|
||||
await message.answer(
|
||||
"⚠️ <b>Режим тишины не активен</b>\n\n"
|
||||
"Активируйте командой: /silence <минуты>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Отключаем режим
|
||||
await manager.disable_silence_mode()
|
||||
|
||||
text = (
|
||||
f"✅ <b>Режим тишины отключен</b>\n\n"
|
||||
f"🔊 Пользователи снова могут отправлять сообщения\n"
|
||||
f"🔄 Банворды снова активны"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
f"Режим тишины отключён пользователем {message.from_user.id}",
|
||||
log_type="SILENCE"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения режима тишины: {e}", log_type="SILENCE")
|
||||
await message.answer("❌ <b>Ошибка отключения режима</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("silencestatus", ["silencestatus"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="SILENCE_STATUS")
|
||||
async def silence_status_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает статус режима тишины.
|
||||
|
||||
Использование: /silencestatus
|
||||
"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Проверяем активность режима
|
||||
is_active = await manager.is_silence_active()
|
||||
|
||||
if is_active:
|
||||
# Режим активен - показываем детали
|
||||
silence_until_str = await manager.repo.get_setting("silence_until")
|
||||
silence_until = float(silence_until_str)
|
||||
expires_at = datetime.fromtimestamp(silence_until)
|
||||
|
||||
now = datetime.now()
|
||||
time_left_seconds = (expires_at - now).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
# Расчёт процента прошедшего времени (для визуализации)
|
||||
# Примерно определяем начальное время
|
||||
started_minutes_ago = 0 # Можно было бы сохранять в БД
|
||||
|
||||
text = (
|
||||
f"🔇 <b>РЕЖИМ ТИШИНЫ АКТИВЕН</b>\n\n"
|
||||
f"⏱ Осталось: {format_time_str(time_left_minutes)}\n"
|
||||
f"🕐 Окончание: {format_datetime(expires_at)}\n\n"
|
||||
f"⚠️ <b>Что происходит:</b>\n"
|
||||
f"├─ Все сообщения от пользователей удаляются\n"
|
||||
f"├─ Администраторы могут писать\n"
|
||||
f"└─ Банворды временно отключены\n\n"
|
||||
f"💡 <i>Для успокоения конфликта или флуда</i>\n"
|
||||
f"Отключить: /unsilence"
|
||||
)
|
||||
|
||||
# Добавляем визуальную шкалу прогресса
|
||||
if time_left_minutes <= 60:
|
||||
progress_bar = create_progress_bar(time_left_minutes, 60)
|
||||
text += f"\n\n{progress_bar}"
|
||||
|
||||
else:
|
||||
# Режим не активен
|
||||
text = (
|
||||
f"💤 <b>Режим тишины НЕ активен</b>\n\n"
|
||||
f"🔊 Пользователи могут отправлять сообщения\n"
|
||||
f"🔄 Банворды работают в обычном режиме\n\n"
|
||||
f"Активировать: /silence <минуты>"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса режима тишины: {e}", log_type="SILENCE")
|
||||
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")
|
||||
|
||||
|
||||
def create_progress_bar(minutes_left: int, total_minutes: int, length: int = 10) -> str:
|
||||
"""
|
||||
Создает визуальную шкалу прогресса.
|
||||
|
||||
Args:
|
||||
minutes_left: Сколько минут осталось
|
||||
total_minutes: Всего минут
|
||||
length: Длина шкалы
|
||||
|
||||
Returns:
|
||||
Строка с визуальной шкалой
|
||||
"""
|
||||
if total_minutes <= 0:
|
||||
filled = 0
|
||||
else:
|
||||
filled = int((total_minutes - minutes_left) / total_minutes * length)
|
||||
|
||||
filled = max(0, min(filled, length))
|
||||
empty = length - filled
|
||||
|
||||
bar = "█" * filled + "░" * empty
|
||||
percentage = int((total_minutes - minutes_left) / total_minutes * 100) if total_minutes > 0 else 0
|
||||
|
||||
return f"[{bar}] {percentage}%"
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("extend_silence", ["extend_silence"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="EXTEND_SILENCE_MODE", log_args=True)
|
||||
async def extend_silence_mode_cmd(message: Message) -> None:
|
||||
"""
|
||||
Продлевает режим тишины на указанное время.
|
||||
|
||||
Использование: /extend_silence <минуты>
|
||||
Пример: /extend_silence 30
|
||||
"""
|
||||
success, result = parse_silence_args(message.text)
|
||||
|
||||
if not success:
|
||||
# Меняем текст ошибки для extend команды
|
||||
await message.answer(
|
||||
"❌ Использование: <code>/extend_silence <минуты></code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем, активен ли режим
|
||||
manager = get_manager()
|
||||
is_active = await manager.is_silence_active()
|
||||
|
||||
if not is_active:
|
||||
await message.answer(
|
||||
"⚠️ <b>Режим тишины не активен</b>\n\n"
|
||||
"Сначала активируйте: /silence <минуты>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
add_minutes = int(result)
|
||||
if add_minutes < 1 or add_minutes > 1440:
|
||||
await message.answer(
|
||||
"❌ Время продления должно быть от 1 до 1440 минут (24 часа)",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
|
||||
return
|
||||
|
||||
try:
|
||||
# Получаем текущее время окончания
|
||||
silence_until_str = await manager.repo.get_setting("silence_until")
|
||||
current_until = float(silence_until_str)
|
||||
current_expires = datetime.fromtimestamp(current_until)
|
||||
|
||||
# Вычисляем сколько минут осталось + добавляем новые
|
||||
now = datetime.now()
|
||||
current_minutes_left = int((current_expires - now).total_seconds() / 60)
|
||||
new_total_minutes = current_minutes_left + add_minutes
|
||||
|
||||
# Устанавливаем новое время
|
||||
new_expires_at = await manager.set_silence_mode(new_total_minutes)
|
||||
|
||||
time_str = format_time_str(add_minutes)
|
||||
new_expires_str = format_datetime(new_expires_at)
|
||||
|
||||
text = (
|
||||
f"⏱ <b>РЕЖИМ ТИШИНЫ ПРОДЛЁН</b>\n\n"
|
||||
f"➕ Добавлено: {time_str}\n"
|
||||
f"🕐 Новое окончание: {new_expires_str}\n"
|
||||
f"⏳ Всего осталось: {format_time_str(new_total_minutes)}\n\n"
|
||||
f"Отключить: /unsilence"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
logger.info(
|
||||
f"Режим тишины продлён на {add_minutes} мин (всего: {new_total_minutes} мин)",
|
||||
log_type="SILENCE"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка продления режима тишины: {e}", log_type="SILENCE")
|
||||
await message.answer("❌ <b>Ошибка продления режима</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
168
bot/handlers/commands/users/start_cmd.py
Normal file
168
bot/handlers/commands/users/start_cmd.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Обработчик команды /start и /help для администраторов.
|
||||
Показывает список доступных команд для управления банвордами.
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
CMD: str = "start"
|
||||
router: Router = Router(name="start_cmd_router")
|
||||
|
||||
def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"):
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text=text, url=url)
|
||||
return ikb.as_markup()
|
||||
|
||||
|
||||
@router.callback_query(F.data.casefold() == CMD)
|
||||
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="START_COMMAND", log_args=True)
|
||||
async def start_cmd(update: Message | CallbackQuery) -> None:
|
||||
"""
|
||||
Обработчик команды /start и /help.
|
||||
Показывает справку по командам бота для администраторов.
|
||||
|
||||
Доступно только администраторам (суперадмин или доп. админ из БД).
|
||||
|
||||
Args:
|
||||
update: Message или CallbackQuery
|
||||
"""
|
||||
print(123)
|
||||
# Определяем тип update и извлекаем данные
|
||||
if isinstance(update, CallbackQuery):
|
||||
message = update.message
|
||||
user_id = update.from_user.id
|
||||
is_callback = True
|
||||
else:
|
||||
message = update
|
||||
user_id = update.from_user.id
|
||||
is_callback = False
|
||||
|
||||
# Проверяем, является ли пользователь суперадмином
|
||||
is_super_admin = user_id in settings.OWNER_ID
|
||||
|
||||
# Формируем текст помощи
|
||||
help_text = (
|
||||
"🤖 <b>PrimoGuard - Бот-модератор</b>\n\n"
|
||||
"Автоматическое удаление сообщений с запрещёнными словами.\n"
|
||||
"Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
|
||||
)
|
||||
|
||||
# === Команды просмотра ===
|
||||
help_text += (
|
||||
"📋 <b>Просмотр:</b>\n"
|
||||
"/list — список всех правил и слов\n"
|
||||
"/stats — статистика по удалениям\n"
|
||||
"/id — получение айди пользователя\n"
|
||||
"/chatid — получение айди чата\n\n"
|
||||
)
|
||||
|
||||
# === Постоянные банворды ===
|
||||
help_text += (
|
||||
"➕ <b>Добавить банворд (постоянно):</b>\n"
|
||||
"/addword <code>слово</code> — подстрока (простой поиск)\n"
|
||||
"/addlemma <code>слово</code> — лемма (все формы слова)\n"
|
||||
"/addpart <code>комбинация</code> — часть (поиск без пробелов)\n\n"
|
||||
)
|
||||
|
||||
# === Временные банворды ===
|
||||
help_text += (
|
||||
"⏱ <b>Добавить банворд (временно):</b>\n"
|
||||
"/addtempword <code>слово минуты</code> — временная подстрока\n"
|
||||
"/addtemplemma <code>слово минуты</code> — временная лемма\n"
|
||||
"<i>Пример: /addtempword спам 60</i>\n\n"
|
||||
)
|
||||
|
||||
# === Исключения (whitelist) ===
|
||||
help_text += (
|
||||
"✅ <b>Исключения (whitelist):</b>\n"
|
||||
"/addexcept <code>текст</code> — добавить исключение\n"
|
||||
"/remexcept <code>текст</code> — удалить исключение\n"
|
||||
"<i>Исключения не проверяются фильтром</i>\n\n"
|
||||
)
|
||||
|
||||
# === Режимы модерации ===
|
||||
help_text += (
|
||||
"🔇 <b>Режим тишины:</b>\n"
|
||||
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n"
|
||||
"/unsilence — отключить режим тишины\n\n"
|
||||
)
|
||||
|
||||
help_text += (
|
||||
"⚔️ <b>Режим антиконфликта:</b>\n"
|
||||
"/addconflictword <code>слово</code> — добавить конфликтное слово\n"
|
||||
"/addconflictlemma <code>слово</code> — добавить конфликтную лемму\n"
|
||||
"/stopconflict <code>минуты</code> — активировать режим\n"
|
||||
"/unstopconflict — отключить режим\n\n"
|
||||
)
|
||||
|
||||
# === Удаление ===
|
||||
help_text += (
|
||||
"➖ <b>Удалить:</b>\n"
|
||||
"/remword <code>слово</code> — удалить подстроку\n"
|
||||
"/remlemma <code>слово</code> — удалить лемму\n"
|
||||
"/rempart <code>комбинация</code> — удалить часть\n"
|
||||
"/remtempword <code>слово</code> — удалить временную подстроку\n"
|
||||
"/remtemplemma <code>слово</code> — удалить временную лемму\n"
|
||||
"/remconflictword <code>слово</code> — удалить конфликтное слово\n"
|
||||
"/remconflictlemma <code>слово</code> — удалить конфликтную лемму\n\n"
|
||||
)
|
||||
|
||||
# === Управление админами (только для суперадминов) ===
|
||||
if is_super_admin:
|
||||
help_text += (
|
||||
"👑 <b>Управление админами (только для владельцев):</b>\n"
|
||||
"/addadmin <code>ID</code> — добавить администратора\n"
|
||||
"/remadmin <code>ID</code> — удалить администратора\n"
|
||||
"/listadmins — список всех админов\n\n"
|
||||
)
|
||||
|
||||
# === Типы проверок ===
|
||||
help_text += (
|
||||
"ℹ️ <b>Типы проверок:</b>\n"
|
||||
"• <b>Подстрока</b> — простой поиск в тексте\n"
|
||||
"• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n"
|
||||
"• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n"
|
||||
"• <b>Временные</b> — автоматически удаляются через N минут\n"
|
||||
"• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n"
|
||||
)
|
||||
|
||||
help_text += (
|
||||
"🔧 <b>Технологии:</b>\n"
|
||||
"• Unicode-нормализация (латиница→кириллица)\n"
|
||||
"• Обход через разделители (\"с п а м\" → \"спам\")\n"
|
||||
"• Морфологический анализ (pymorphy3)\n"
|
||||
"• SQLAlchemy + SQLite с кэшированием\n\n"
|
||||
"💾 Все настройки сохраняются в базе данных"
|
||||
)
|
||||
|
||||
# Отправляем ответ
|
||||
try:
|
||||
if is_callback:
|
||||
await message.edit_text(
|
||||
text=help_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb()
|
||||
)
|
||||
await update.answer()
|
||||
else:
|
||||
await message.answer(
|
||||
text=help_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки help сообщения: {e}",
|
||||
log_type="ERROR"
|
||||
)
|
||||
if is_callback:
|
||||
await update.answer("❌ Ошибка отображения справки", show_alert=True)
|
||||
589
bot/handlers/commands/users/stats.py
Normal file
589
bot/handlers/commands/users/stats.py
Normal file
@@ -0,0 +1,589 @@
|
||||
"""
|
||||
Обработчики команды статистики
|
||||
"""
|
||||
from datetime import datetime
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="stats_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def format_number(num: int) -> str:
|
||||
"""Форматирует большие числа с разделителями"""
|
||||
return f"{num:,}".replace(",", " ")
|
||||
|
||||
|
||||
def create_text_bar(value: int, max_value: int, length: int = 10) -> str:
|
||||
"""Создает текстовую полоску прогресса"""
|
||||
if max_value == 0:
|
||||
return "░" * length
|
||||
|
||||
filled = int((value / max_value) * length)
|
||||
filled = max(0, min(filled, length))
|
||||
empty = length - filled
|
||||
|
||||
return "█" * filled + "░" * empty
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Форматирует datetime в читабельный формат"""
|
||||
return dt.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def format_time_remaining(minutes: int) -> str:
|
||||
"""
|
||||
Форматирует оставшееся время в читабельный формат.
|
||||
|
||||
Args:
|
||||
minutes: Количество минут
|
||||
|
||||
Returns:
|
||||
Отформатированная строка времени
|
||||
"""
|
||||
if minutes <= 0:
|
||||
return "истёк"
|
||||
elif minutes < 60:
|
||||
return f"{minutes} мин"
|
||||
elif minutes < 1440: # < 24 часов
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
if mins > 0:
|
||||
return f"{hours}ч {mins}м"
|
||||
return f"{hours}ч"
|
||||
else: # >= 24 часов
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
if hours > 0:
|
||||
return f"{days}д {hours}ч"
|
||||
return f"{days}д"
|
||||
|
||||
|
||||
def get_stats_keyboard():
|
||||
"""Клавиатура для статистики"""
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="🔄 Обновить", callback_data="stats:refresh")
|
||||
ikb.button(text="📊 Детали", callback_data="stats:details")
|
||||
ikb.button(text="🏆 Топ-спамеры", callback_data="stats:top_spammers")
|
||||
ikb.button(text="🔤 Топ-слова", callback_data="stats:top_words")
|
||||
ikb.button(text="🚀 Назад", callback_data="start")
|
||||
ikb.adjust(2, 2, 1)
|
||||
return ikb.as_markup()
|
||||
|
||||
|
||||
# ================= ОСНОВНАЯ СТАТИСТИКА =================
|
||||
|
||||
@router.callback_query(F.data == "stats:refresh")
|
||||
@router.callback_query(F.data == "stats")
|
||||
@router.message(Command(*COMMANDS.get("stats", ["stats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="VIEW_STATS")
|
||||
async def stats_cmd(update: Message | CallbackQuery) -> None:
|
||||
"""
|
||||
Показывает общую статистику работы бота.
|
||||
|
||||
Включает:
|
||||
- Общее количество удалений
|
||||
- Активные режимы
|
||||
- Статистику банвордов
|
||||
- Топ спамеров
|
||||
|
||||
Использование: /stats
|
||||
"""
|
||||
# Определяем тип update
|
||||
if isinstance(update, CallbackQuery):
|
||||
message = update.message
|
||||
is_callback = True
|
||||
else:
|
||||
message = update
|
||||
is_callback = False
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Получаем данные
|
||||
stats = await manager.get_stats()
|
||||
data = await manager.get_all_words_list()
|
||||
top_spammers = await manager.get_top_spammers(limit=5)
|
||||
|
||||
# Проверяем активные режимы
|
||||
is_silence = await manager.is_silence_active()
|
||||
is_conflict = await manager.is_conflict_active()
|
||||
|
||||
# === ФОРМИРУЕМ ВЫВОД ===
|
||||
|
||||
output = "📊 <b>СТАТИСТИКА PRIMOGUARD</b>\n\n"
|
||||
|
||||
# Общая информация
|
||||
total_deletions = stats.get('total_deletions', 0)
|
||||
output += f"🗑 <b>Всего удалений:</b> <code>{format_number(total_deletions)}</code>\n\n"
|
||||
|
||||
# Активные режимы
|
||||
if is_silence or is_conflict:
|
||||
output += "🔴 <b>АКТИВНЫЕ РЕЖИМЫ:</b>\n\n"
|
||||
|
||||
if is_silence:
|
||||
silence_until_str = await manager.repo.get_setting("silence_until")
|
||||
silence_until = datetime.fromtimestamp(float(silence_until_str))
|
||||
time_left_seconds = (silence_until - datetime.now()).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
output += f"🔇 <b>Режим тишины</b>\n"
|
||||
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
||||
output += f"└─ 🕐 До: {format_datetime(silence_until)}\n"
|
||||
|
||||
if is_conflict:
|
||||
output += "│\n"
|
||||
|
||||
if is_conflict:
|
||||
conflict_until_str = await manager.repo.get_setting("conflict_until")
|
||||
conflict_until = datetime.fromtimestamp(float(conflict_until_str))
|
||||
time_left_seconds = (conflict_until - datetime.now()).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
conflict_words_count = len(data.get('conflict_substring', set()))
|
||||
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
||||
total_conflict = conflict_words_count + conflict_lemmas_count
|
||||
|
||||
output += f"⚔️ <b>Режим антиконфликта</b>\n"
|
||||
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
||||
output += f"├─ 🕐 До: {format_datetime(conflict_until)}\n"
|
||||
output += f"└─ 📊 Правил: <code>{total_conflict}</code>\n"
|
||||
|
||||
output += "\n"
|
||||
|
||||
# Статистика правил
|
||||
total_rules = (
|
||||
len(data.get('substring', set())) +
|
||||
len(data.get('lemma', set())) +
|
||||
len(data.get('part', set())) +
|
||||
len(data.get('temp_substring', set())) +
|
||||
len(data.get('temp_lemma', set())) +
|
||||
len(data.get('conflict_substring', set())) +
|
||||
len(data.get('conflict_lemma', set()))
|
||||
)
|
||||
|
||||
output += f"📋 <b>Правила модерации:</b>\n"
|
||||
output += f"├─ Всего правил: <code>{total_rules}</code>\n"
|
||||
output += f"├─ Постоянные: <code>{len(data.get('substring', set())) + len(data.get('lemma', set())) + len(data.get('part', set()))}</code>\n"
|
||||
output += f"├─ Временные: <code>{len(data.get('temp_substring', set())) + len(data.get('temp_lemma', set()))}</code>\n"
|
||||
output += f"├─ Конфликтные: <code>{len(data.get('conflict_substring', set())) + len(data.get('conflict_lemma', set()))}</code>\n"
|
||||
output += f"└─ Исключения: <code>{len(data.get('whitelist', set()))}</code>\n\n"
|
||||
|
||||
# Топ-5 спамеров
|
||||
if top_spammers:
|
||||
output += "🏆 <b>Топ-5 спамеров:</b>\n"
|
||||
max_count = top_spammers[0][1] if top_spammers else 1
|
||||
|
||||
for idx, (user_id, count) in enumerate(top_spammers, 1):
|
||||
bar = create_text_bar(count, max_count, length=8)
|
||||
output += f"{idx}. <code>{user_id}</code> — {count} [{bar}]\n"
|
||||
|
||||
output += "\n"
|
||||
else:
|
||||
output += "🏆 <b>Топ-5 спамеров:</b>\n"
|
||||
output += "└─ <i>Нет данных</i>\n\n"
|
||||
|
||||
# Администраторы
|
||||
admins_count = len(settings.OWNER_ID) + len(data.get('admins', set()))
|
||||
output += f"👥 <b>Администраторов:</b> <code>{admins_count}</code>\n\n"
|
||||
|
||||
# Подсказка
|
||||
output += "💡 <i>Используйте кнопки для детальной информации</i>"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = get_stats_keyboard()
|
||||
|
||||
# Отправка
|
||||
if is_callback:
|
||||
await message.edit_text(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await update.answer("✅ Статистика обновлена")
|
||||
else:
|
||||
await message.answer(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики: {e}", log_type="STATS")
|
||||
|
||||
error_text = "❌ <b>Ошибка загрузки статистики</b>\n\nПопробуйте позже"
|
||||
|
||||
if is_callback:
|
||||
await update.answer("❌ Ошибка загрузки", show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text, parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= ДЕТАЛЬНАЯ СТАТИСТИКА =================
|
||||
|
||||
@router.callback_query(F.data == "stats:details")
|
||||
@log_action(action_name="VIEW_DETAILED_STATS")
|
||||
async def stats_details_callback(callback: CallbackQuery) -> None:
|
||||
"""Показывает детальную статистику"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
stats = await manager.get_stats()
|
||||
data = await manager.get_all_words_list()
|
||||
|
||||
output = "📊 <b>ДЕТАЛЬНАЯ СТАТИСТИКА</b>\n\n"
|
||||
|
||||
# Подробная статистика удалений
|
||||
total_deletions = stats.get('total_deletions', 0)
|
||||
output += f"🗑 <b>Удаления сообщений:</b>\n"
|
||||
output += f"├─ Всего: <code>{format_number(total_deletions)}</code>\n"
|
||||
output += "\n"
|
||||
|
||||
# Активные режимы (детально)
|
||||
is_silence = await manager.is_silence_active()
|
||||
is_conflict = await manager.is_conflict_active()
|
||||
|
||||
if is_silence or is_conflict:
|
||||
output += "🔴 <b>Активные режимы:</b>\n\n"
|
||||
|
||||
if is_silence:
|
||||
silence_until_str = await manager.repo.get_setting("silence_until")
|
||||
silence_until = datetime.fromtimestamp(float(silence_until_str))
|
||||
time_left_seconds = (silence_until - datetime.now()).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
output += f"🔇 <b>Режим тишины:</b>\n"
|
||||
output += f"├─ Статус: ✅ Активен\n"
|
||||
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
||||
output += f"├─ Окончание: {format_datetime(silence_until)}\n"
|
||||
output += f"└─ Эффект: Удаляются ВСЕ сообщения\n\n"
|
||||
|
||||
if is_conflict:
|
||||
conflict_until_str = await manager.repo.get_setting("conflict_until")
|
||||
conflict_until = datetime.fromtimestamp(float(conflict_until_str))
|
||||
time_left_seconds = (conflict_until - datetime.now()).total_seconds()
|
||||
time_left_minutes = int(time_left_seconds / 60)
|
||||
|
||||
conflict_words_count = len(data.get('conflict_substring', set()))
|
||||
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
||||
|
||||
output += f"⚔️ <b>Режим антиконфликта:</b>\n"
|
||||
output += f"├─ Статус: ✅ Активен\n"
|
||||
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
||||
output += f"├─ Окончание: {format_datetime(conflict_until)}\n"
|
||||
output += f"├─ Слов: <code>{conflict_words_count}</code>\n"
|
||||
output += f"├─ Лемм: <code>{conflict_lemmas_count}</code>\n"
|
||||
output += f"└─ Эффект: Обычные банворды отключены\n\n"
|
||||
|
||||
# Детальная статистика правил
|
||||
output += f"📋 <b>Правила модерации:</b>\n\n"
|
||||
|
||||
output += f"🔴 <b>Постоянные:</b>\n"
|
||||
output += f"├─ Подстроки: <code>{len(data.get('substring', set()))}</code>\n"
|
||||
output += f"├─ Леммы: <code>{len(data.get('lemma', set()))}</code>\n"
|
||||
output += f"└─ Части: <code>{len(data.get('part', set()))}</code>\n\n"
|
||||
|
||||
output += f"⏱ <b>Временные:</b>\n"
|
||||
output += f"├─ Подстроки: <code>{len(data.get('temp_substring', set()))}</code>\n"
|
||||
output += f"└─ Леммы: <code>{len(data.get('temp_lemma', set()))}</code>\n\n"
|
||||
|
||||
output += f"⚔️ <b>Конфликтные:</b>\n"
|
||||
output += f"├─ Слова: <code>{len(data.get('conflict_substring', set()))}</code>\n"
|
||||
output += f"└─ Леммы: <code>{len(data.get('conflict_lemma', set()))}</code>\n\n"
|
||||
|
||||
output += f"✅ <b>Исключения:</b> <code>{len(data.get('whitelist', set()))}</code>\n\n"
|
||||
|
||||
# Информация о кэше
|
||||
cache_info = stats.get('cache_active', False)
|
||||
cache_updated = stats.get('cache_updated_at', None)
|
||||
|
||||
output += f"💾 <b>Кэш:</b>\n"
|
||||
output += f"├─ Статус: {'✅ Активен' if cache_info else '❌ Неактивен'}\n"
|
||||
|
||||
if cache_updated and isinstance(cache_updated, str):
|
||||
try:
|
||||
updated_dt = datetime.fromisoformat(cache_updated)
|
||||
output += f"└─ Обновлён: {format_datetime(updated_dt)}\n"
|
||||
except (ValueError, TypeError):
|
||||
output += f"└─ Обновлён: недавно\n"
|
||||
else:
|
||||
output += f"└─ Не обновлялся\n"
|
||||
|
||||
# Кнопка возврата
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="◀️ Назад", callback_data="stats:refresh")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=ikb.as_markup()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения детальной статистики: {e}", log_type="STATS")
|
||||
await callback.answer("❌ Ошибка загрузки", show_alert=True)
|
||||
|
||||
|
||||
# ================= ТОП СПАМЕРОВ =================
|
||||
|
||||
@router.callback_query(F.data == "stats:top_spammers")
|
||||
@log_action(action_name="VIEW_TOP_SPAMMERS")
|
||||
async def stats_top_spammers_callback(callback: CallbackQuery) -> None:
|
||||
"""Показывает топ-10 спамеров"""
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
top_spammers = await manager.get_top_spammers(limit=10)
|
||||
|
||||
output = "🏆 <b>ТОП-10 СПАМЕРОВ</b>\n\n"
|
||||
|
||||
if top_spammers:
|
||||
max_count = top_spammers[0][1] if top_spammers else 1
|
||||
|
||||
for idx, (user_id, count) in enumerate(top_spammers, 1):
|
||||
bar = create_text_bar(count, max_count, length=10)
|
||||
|
||||
# Эмодзи для топ-3
|
||||
if idx == 1:
|
||||
medal = "🥇"
|
||||
elif idx == 2:
|
||||
medal = "🥈"
|
||||
elif idx == 3:
|
||||
medal = "🥉"
|
||||
else:
|
||||
medal = f"{idx}."
|
||||
|
||||
output += f"{medal} <code>{user_id}</code>\n"
|
||||
output += f" └─ {format_number(count)} удалений [{bar}]\n\n"
|
||||
|
||||
# Общая статистика
|
||||
total_spammers = len(top_spammers)
|
||||
total_deletions = sum(count for _, count in top_spammers)
|
||||
|
||||
output += f"📊 <b>Статистика:</b>\n"
|
||||
output += f"├─ Всего пользователей: <code>{total_spammers}</code>\n"
|
||||
output += f"└─ Всего удалений: <code>{format_number(total_deletions)}</code>\n\n"
|
||||
|
||||
output += "💡 <i>ID можно использовать для проверки пользователя</i>"
|
||||
else:
|
||||
output += "└─ <i>Нет данных об удалениях</i>\n\n"
|
||||
output += "💡 <i>Когда бот начнёт удалять сообщения, здесь появится статистика</i>"
|
||||
|
||||
# Кнопка возврата
|
||||
ikb = InlineKeyboardBuilder()
|
||||
ikb.button(text="◀️ Назад", callback_data="stats:refresh")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=output,
|
||||
parse_mode="HTML",
|
||||
reply_markup=ikb.as_markup()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения топ спамеров: {e}", log_type="STATS")
|
||||
await callback.answer("❌ Ошибка загрузки", show_alert=True)
|
||||
|
||||
|
||||
# ================= ТОП СЛОВ =================
|
||||
|
||||
@router.callback_query(F.data == "stats_top_words")
|
||||
async def stats_top_words_callback(callback: CallbackQuery) -> None:
|
||||
"""Показывает топ-10 самых частых срабатываний"""
|
||||
await callback.answer()
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
# Получаем топ слов
|
||||
top_words = await manager.get_top_words(limit=10)
|
||||
|
||||
if not top_words:
|
||||
text = (
|
||||
"🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\n\n"
|
||||
"📭 <i>Статистика пока пуста</i>\n\n"
|
||||
"Срабатывания появятся после удаления\n"
|
||||
"первых спам-сообщений."
|
||||
)
|
||||
else:
|
||||
text = "🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\n\n"
|
||||
|
||||
# Эмодзи для типов
|
||||
type_emoji = {
|
||||
"substring": "🔤",
|
||||
"lemma": "📖",
|
||||
"part": "🧩",
|
||||
"silence": "🔇",
|
||||
"conflict_substring": "⚔️",
|
||||
"conflict_lemma": "⚔️"
|
||||
}
|
||||
|
||||
for i, word_data in enumerate(top_words, 1):
|
||||
word = word_data['word']
|
||||
count = word_data['count']
|
||||
word_type = word_data['type']
|
||||
emoji = type_emoji.get(word_type, "❓")
|
||||
|
||||
# Медали для топ-3
|
||||
medal = ""
|
||||
if i == 1:
|
||||
medal = "🥇 "
|
||||
elif i == 2:
|
||||
medal = "🥈 "
|
||||
elif i == 3:
|
||||
medal = "🥉 "
|
||||
|
||||
text += f"{medal}<b>{i}.</b> {emoji} <code>{word}</code> — {count} раз\n"
|
||||
|
||||
# Общая статистика
|
||||
total = await manager.get_total_spam_count()
|
||||
text += f"\n📊 <b>Всего удалено:</b> {total} сообщений"
|
||||
|
||||
# Кнопка назад
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="show_stats")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа топ-слов: {e}", log_type="ERROR")
|
||||
await callback.answer("❌ Ошибка загрузки статистики", show_alert=True)
|
||||
|
||||
|
||||
# ================= СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("userstats", ["userstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="VIEW_USER_STATS", log_args=True)
|
||||
async def user_stats_cmd(message: Message) -> None:
|
||||
"""
|
||||
Показывает статистику конкретного пользователя.
|
||||
|
||||
Использование: /userstats <ID>
|
||||
Пример: /userstats 123456789
|
||||
"""
|
||||
parts = message.text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
await message.answer(
|
||||
"❌ Использование: <code>/userstats [ID]</code>\n\n"
|
||||
"Пример: <code>/userstats 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
user_id = int(parts[1].strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ ID должен быть числом", parse_mode="HTML")
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Получаем статистику пользователя
|
||||
user_spam_count = await manager.get_user_spam_count(user_id)
|
||||
user_spam_stats = await manager.get_spam_stats(limit=10, user_id=user_id)
|
||||
|
||||
output = f"👤 <b>СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ</b>\n\n"
|
||||
output += f"🆔 ID: <code>{user_id}</code>\n\n"
|
||||
|
||||
if user_spam_count > 0:
|
||||
output += f"🗑 <b>Удалено сообщений:</b> <code>{format_number(user_spam_count)}</code>\n\n"
|
||||
|
||||
if user_spam_stats:
|
||||
output += f"📝 <b>Последние удаления:</b>\n"
|
||||
|
||||
for stat in user_spam_stats[:5]:
|
||||
deleted_at = stat.deleted_at
|
||||
matched_word = stat.matched_word or "неизвестно"
|
||||
match_type = stat.match_type or "unknown"
|
||||
|
||||
output += f"├─ {format_datetime(deleted_at)}\n"
|
||||
output += f"│ └─ Слово: <code>{matched_word}</code> ({match_type})\n"
|
||||
|
||||
output += "\n"
|
||||
else:
|
||||
output += "✅ <i>Нет нарушений</i>\n\n"
|
||||
output += "Этот пользователь не нарушал правила чата"
|
||||
|
||||
await message.answer(output, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики пользователя: {e}", log_type="STATS")
|
||||
await message.answer("❌ <b>Ошибка загрузки статистики</b>", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= СБРОС СТАТИСТИКИ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("resetstats", ["resetstats"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="RESET_STATS")
|
||||
async def reset_stats_cmd(message: Message) -> None:
|
||||
"""
|
||||
Сбрасывает всю статистику удалений.
|
||||
|
||||
⚠️ ВНИМАНИЕ: Это действие необратимо!
|
||||
|
||||
Использование: /resetstats confirm
|
||||
"""
|
||||
parts = message.text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2 or parts[1].lower() != "confirm":
|
||||
await message.answer(
|
||||
"⚠️ <b>ВНИМАНИЕ!</b>\n\n"
|
||||
"Эта команда удалит ВСЮ статистику удалений:\n"
|
||||
"• Счётчики удалений пользователей\n"
|
||||
"• Историю удалённых сообщений\n"
|
||||
"• Топ спамеров\n\n"
|
||||
"Правила модерации НЕ будут удалены.\n\n"
|
||||
"Для подтверждения используйте:\n"
|
||||
"<code>/resetstats confirm</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
# Сбрасываем статистику
|
||||
deleted_count = await manager.reset_spam_stats()
|
||||
|
||||
if deleted_count > 0:
|
||||
await message.answer(
|
||||
f"✅ <b>Статистика сброшена</b>\n\n"
|
||||
f"Удалено записей: {deleted_count}\n\n"
|
||||
f"Новые данные начнут собираться\n"
|
||||
f"с этого момента.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.warning(
|
||||
f"Статистика сброшена пользователем {message.from_user.id}: "
|
||||
f"удалено {deleted_count} записей",
|
||||
log_type="STATS"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"ℹ️ <b>Статистика уже пуста</b>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сброса статистики: {e}", log_type="STATS")
|
||||
await message.answer("❌ <b>Ошибка сброса статистики</b>", parse_mode="HTML")
|
||||
|
||||
546
bot/handlers/commands/users/word.py
Normal file
546
bot/handlers/commands/users/word.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
Обработчики команд добавления и удаления банвордов
|
||||
"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.filters.admin import IsAdmin
|
||||
from configs import settings, COMMANDS
|
||||
from database import get_manager
|
||||
from database.models import BanWordType
|
||||
from middleware.loggers import logger
|
||||
from bot.utils.decorators import log_action
|
||||
|
||||
__all__ = ("router",)
|
||||
|
||||
router: Router = Router(name="manage_words_router")
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def parse_args(text: str, command: str, min_args: int = 1, max_args: int = 2) -> tuple[bool, str | list]:
|
||||
"""
|
||||
Парсит аргументы команды.
|
||||
|
||||
Args:
|
||||
text: Полный текст сообщения
|
||||
command: Название команды
|
||||
min_args: Минимальное количество аргументов
|
||||
max_args: Максимальное количество аргументов
|
||||
|
||||
Returns:
|
||||
(success, result): result это либо список аргументов, либо текст ошибки
|
||||
"""
|
||||
# Убираем команду из текста
|
||||
parts = text.split(maxsplit=max_args)
|
||||
|
||||
if len(parts) < min_args + 1:
|
||||
return False, f"❌ Использование: <code>/{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}</code>"
|
||||
|
||||
args = parts[1:]
|
||||
|
||||
# Валидация длины слова
|
||||
if args and len(args[0]) < 2:
|
||||
return False, "❌ Слово должно содержать минимум 2 символа"
|
||||
|
||||
if args and len(args[0]) > 100:
|
||||
return False, "❌ Слово слишком длинное (максимум 100 символов)"
|
||||
|
||||
return True, args
|
||||
|
||||
|
||||
def format_success_message(action: str, word: str, word_type: str, extra: str = "") -> str:
|
||||
"""Форматирует сообщение об успехе"""
|
||||
emoji_map = {
|
||||
'добавлена': '✅',
|
||||
'добавлен': '✅',
|
||||
'добавлено': '✅',
|
||||
'удалена': '🗑',
|
||||
'удален': '🗑',
|
||||
'удалено': '🗑'
|
||||
}
|
||||
|
||||
emoji = emoji_map.get(action, '✅')
|
||||
|
||||
message = f"{emoji} <b>{word_type.capitalize()}</b> <code>{word}</code> {action}"
|
||||
|
||||
if extra:
|
||||
message += f"\n{extra}"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# ================= КОМАНДЫ ДОБАВЛЕНИЯ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addword", ["addword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="ADD_WORD", log_args=True)
|
||||
async def add_word_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет банворд-подстроку (постоянно).
|
||||
|
||||
Использование: /addword <слово>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addword", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.SUBSTRING,
|
||||
added_by=message.from_user.id,
|
||||
reason=f"Добавлено через команду"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
"подстрока",
|
||||
"🔍 Тип проверки: простой поиск в тексте"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Подстрока <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addlemma", ["addlemma"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="ADD_LEMMA", log_args=True)
|
||||
async def add_lemma_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет банворд-лемму (постоянно).
|
||||
|
||||
Использование: /addlemma <слово>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addlemma", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.LEMMA,
|
||||
added_by=message.from_user.id,
|
||||
reason=f"Добавлено через команду"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
"лемма",
|
||||
"🔤 Тип проверки: все формы слова (купить→куплю, купил, купишь...)"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Лемма <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addpart", ["addpart"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="ADD_PART", log_args=True)
|
||||
async def add_part_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет банворд-часть (постоянно).
|
||||
|
||||
Использование: /addpart <комбинация>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addpart", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.PART,
|
||||
added_by=message.from_user.id,
|
||||
reason=f"Добавлено через команду"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
"часть",
|
||||
"🧩 Тип проверки: поиск без пробелов (обходит \"к у п и т ь\")"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Часть <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления части: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addtempword", ["addtempword"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="ADD_TEMP_WORD", log_args=True)
|
||||
async def add_temp_word_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет временную банворд-подстроку.
|
||||
|
||||
Использование: /addtempword <слово> <минуты>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addtempword", min_args=2, max_args=2)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
|
||||
# Валидация минут
|
||||
try:
|
||||
minutes = int(result[1])
|
||||
if minutes < 1 or minutes > 10080: # Максимум неделя
|
||||
await message.answer("❌ Время должно быть от 1 минуты до 10080 минут (7 дней)", parse_mode="HTML")
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_temp_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.SUBSTRING,
|
||||
minutes=minutes,
|
||||
added_by=message.from_user.id
|
||||
)
|
||||
|
||||
if added:
|
||||
# Форматируем время
|
||||
if minutes < 60:
|
||||
time_str = f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
"временная подстрока",
|
||||
f"⏱ Автоматически удалится через {time_str}"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Временная подстрока <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addtemplemma", ["addtemplemma"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="ADD_TEMP_LEMMA", log_args=True)
|
||||
async def add_temp_lemma_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет временную банворд-лемму.
|
||||
|
||||
Использование: /addtemplemma <слово> <минуты>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addtemplemma", min_args=2, max_args=2)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
|
||||
try:
|
||||
minutes = int(result[1])
|
||||
if minutes < 1 or minutes > 10080:
|
||||
await message.answer("❌ Время должно быть от 1 минуты до 10080 минут (7 дней)", parse_mode="HTML")
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат времени. Укажите число минут", parse_mode="HTML")
|
||||
return
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_temp_banword(
|
||||
word=word,
|
||||
word_type=BanWordType.LEMMA,
|
||||
minutes=minutes,
|
||||
added_by=message.from_user.id
|
||||
)
|
||||
|
||||
if added:
|
||||
if minutes < 60:
|
||||
time_str = f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
"временная лемма",
|
||||
f"⏱ Автоматически удалится через {time_str}"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Временная лемма <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addexcept", ["addexcept"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="ADD_EXCEPTION", log_args=True)
|
||||
async def add_exception_cmd(message: Message) -> None:
|
||||
"""
|
||||
Добавляет исключение в whitelist.
|
||||
|
||||
Использование: /addexcept <текст>
|
||||
"""
|
||||
success, result = parse_args(message.text, "addexcept", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
added = await manager.add_whitelist(
|
||||
word=word,
|
||||
added_by=message.from_user.id,
|
||||
reason="Добавлено через команду"
|
||||
)
|
||||
|
||||
if added:
|
||||
text = format_success_message(
|
||||
"добавлено",
|
||||
word,
|
||||
"исключение",
|
||||
"✅ Сообщения с этим текстом не будут проверяться"
|
||||
)
|
||||
else:
|
||||
text = f"⚠️ Исключение <code>{word}</code> уже существует"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
# ================= КОМАНДЫ УДАЛЕНИЯ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remword", ["remword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="REMOVE_WORD", log_args=True)
|
||||
async def remove_word_cmd(message: Message) -> None:
|
||||
"""
|
||||
Удаляет банворд-подстроку.
|
||||
|
||||
Использование: /remword <слово>
|
||||
"""
|
||||
success, result = parse_args(message.text, "remword", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_banword(word=word, word_type=BanWordType.SUBSTRING)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалена", word, "подстрока")
|
||||
else:
|
||||
text = f"⚠️ Подстрока <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remlemma", ["remlemma"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="REMOVE_LEMMA", log_args=True)
|
||||
async def remove_lemma_cmd(message: Message) -> None:
|
||||
"""Удаляет банворд-лемму"""
|
||||
success, result = parse_args(message.text, "remlemma", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_banword(word=word, word_type=BanWordType.LEMMA)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалена", word, "лемма")
|
||||
else:
|
||||
text = f"⚠️ Лемма <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("rempart", ["rempart"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="REMOVE_PART", log_args=True)
|
||||
async def remove_part_cmd(message: Message) -> None:
|
||||
"""Удаляет банворд-часть"""
|
||||
success, result = parse_args(message.text, "rempart", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_banword(word=word, word_type=BanWordType.PART)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалена", word, "часть")
|
||||
else:
|
||||
text = f"⚠️ Часть <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления части: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remtempword", ["remtempword"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="REMOVE_TEMP_WORD", log_args=True)
|
||||
async def remove_temp_word_cmd(message: Message) -> None:
|
||||
"""Удаляет временную подстроку"""
|
||||
success, result = parse_args(message.text, "remtempword", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.SUBSTRING)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалена", word, "временная подстрока")
|
||||
else:
|
||||
text = f"⚠️ Временная подстрока <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remtemplemma", ["remtemplemma"]), prefix=settings.PREFIX, ignore_case=True),
|
||||
IsAdmin())
|
||||
@log_action(action_name="REMOVE_TEMP_LEMMA", log_args=True)
|
||||
async def remove_temp_lemma_cmd(message: Message) -> None:
|
||||
"""Удаляет временную лемму"""
|
||||
success, result = parse_args(message.text, "remtemplemma", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.LEMMA)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалена", word, "временная лемма")
|
||||
else:
|
||||
text = f"⚠️ Временная лемма <code>{word}</code> не найдена"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command(*COMMANDS.get("remexcept", ["remexcept"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@log_action(action_name="REMOVE_EXCEPTION", log_args=True)
|
||||
async def remove_exception_cmd(message: Message) -> None:
|
||||
"""Удаляет исключение из whitelist"""
|
||||
success, result = parse_args(message.text, "remexcept", min_args=1, max_args=1)
|
||||
|
||||
if not success:
|
||||
await message.answer(result, parse_mode="HTML")
|
||||
return
|
||||
|
||||
word = result[0].lower().strip()
|
||||
manager = get_manager()
|
||||
|
||||
try:
|
||||
removed = await manager.remove_whitelist(word=word)
|
||||
|
||||
if removed:
|
||||
text = format_success_message("удалено", word, "исключение")
|
||||
else:
|
||||
text = f"⚠️ Исключение <code>{word}</code> не найдено"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD")
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
15
bot/handlers/messages/__init__.py
Normal file
15
bot/handlers/messages/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .default_msg import router as default_message_router
|
||||
from .ping_test import router as ping_test_message_router
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
# Подготовка роутера команд
|
||||
# router.include_routers(
|
||||
# ping_test_message_router,
|
||||
# )
|
||||
|
||||
# Подключение стандартного роутера
|
||||
router.include_router(default_message_router)
|
||||
11
bot/handlers/messages/default_msg.py
Normal file
11
bot/handlers/messages/default_msg.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
# Настройки экспорта и роутера
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
|
||||
@router.message()
|
||||
async def default_msg(message: Message) -> None:
|
||||
"""Обработчик всех необработанных сообщений."""
|
||||
return
|
||||
32
bot/handlers/messages/ping_test.py
Normal file
32
bot/handlers/messages/ping_test.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
router: Router = Router(name=__name__)
|
||||
|
||||
# Словарь с ответами по ключам
|
||||
RESPONSE_DICT: dict[str, str] = {
|
||||
"пинг": "Понг! 🏓",
|
||||
"понг": "Пинг!",
|
||||
"бот": "На месте! 🤖",
|
||||
}
|
||||
|
||||
|
||||
@router.message()
|
||||
async def auto_response_handler(message: Message) -> None:
|
||||
"""Обработчик автоматических ответов по ключевым словам."""
|
||||
if not message.text:
|
||||
return
|
||||
|
||||
text_lower: str = message.text.casefold().strip()
|
||||
|
||||
# Поиск точного совпадения
|
||||
if text_lower in RESPONSE_DICT:
|
||||
response: str = RESPONSE_DICT[text_lower]
|
||||
await message.answer(response)
|
||||
return
|
||||
|
||||
# Поиск частичного совпадения (если хотите расширенную функциональность)
|
||||
for key, response in RESPONSE_DICT.items():
|
||||
if key in text_lower and len(key) > 3: # Только для ключей длиннее 3 символов
|
||||
await message.answer(response)
|
||||
return
|
||||
2
bot/keyboards/__init__.py
Normal file
2
bot/keyboards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .inline import *
|
||||
from .reply import *
|
||||
17
bot/keyboards/inline.py
Normal file
17
bot/keyboards/inline.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Получение клавиатуры Принятия\Отклонить.
|
||||
|
||||
:param thread_id: Айди действия.
|
||||
:param kind: Вид для клавиатуры.
|
||||
:return: Инлайн-клавиатуру (Принять, Отклонить).
|
||||
"""
|
||||
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
|
||||
ikb.row(
|
||||
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
|
||||
)
|
||||
return ikb.as_markup()
|
||||
1
bot/keyboards/inline/__init__.py
Normal file
1
bot/keyboards/inline/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .decision import *
|
||||
18
bot/keyboards/inline/decision.py
Normal file
18
bot/keyboards/inline/decision.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
|
||||
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Получение клавиатуры Принятия\Отклонить.
|
||||
|
||||
:param thread_id: Айди действия.
|
||||
:param kind: Вид для клавиатуры.
|
||||
:return: Инлайн-клавиатуру (Принять, Отклонить).
|
||||
"""
|
||||
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
|
||||
ikb.row(
|
||||
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
|
||||
)
|
||||
return ikb.as_markup()
|
||||
0
bot/keyboards/reply.py
Normal file
0
bot/keyboards/reply.py
Normal file
0
bot/keyboards/reply/__init__.py
Normal file
0
bot/keyboards/reply/__init__.py
Normal file
137
bot/middlewares/__init__.py
Normal file
137
bot/middlewares/__init__.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Middleware для бота PrimoGuardBot.
|
||||
|
||||
Порядок выполнения middleware важен:
|
||||
1. TimingMiddleware - замер времени выполнения
|
||||
2. LoggingMiddleware - логирование всех событий
|
||||
3. BanCheckMiddleware - проверка статуса бана (блокирует забаненных)
|
||||
4. ErrorHandlingMiddleware - обработка ошибок (последний)
|
||||
|
||||
Message-level middleware:
|
||||
1. RateLimitMiddleware/AntiSpamMiddleware - защита от флуда
|
||||
2. SubscriptionMiddleware - проверка подписки на каналы
|
||||
3. ReferralMiddleware - обработка реферальных ссылок
|
||||
"""
|
||||
from aiogram import Dispatcher, Bot
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
from .error_mdw import ErrorHandlingMiddleware
|
||||
from .logging_mdw import LoggingMiddleware
|
||||
from .referal_mdw import ReferralMiddleware
|
||||
from .spam_mdw import AntiSpamMiddleware, spam_stats
|
||||
from .sub_mdw import SubscriptionMiddleware
|
||||
from .time_mdw import TimingMiddleware
|
||||
from .banwords_mdw import BanWordsMiddleware
|
||||
|
||||
__all__ = (
|
||||
# Middleware классы
|
||||
"TimingMiddleware",
|
||||
"LoggingMiddleware",
|
||||
"ErrorHandlingMiddleware",
|
||||
"AntiSpamMiddleware",
|
||||
"SubscriptionMiddleware",
|
||||
"ReferralMiddleware",
|
||||
"BanWordsMiddleware",
|
||||
|
||||
# Статистика
|
||||
"spam_stats",
|
||||
|
||||
# Утилиты
|
||||
"setup_middlewares",
|
||||
)
|
||||
|
||||
|
||||
def setup_middlewares(
|
||||
dp: Dispatcher,
|
||||
bot: Bot,
|
||||
admin_ids: list[int] = settings.ADMIN_ID+settings.OWNER_ID,
|
||||
channel_ids: list[int | str] | None = None,
|
||||
enable_spam_check: bool = False,
|
||||
enable_subscription_check: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Регистрирует все middleware в диспетчере.
|
||||
|
||||
Args:
|
||||
dp: Диспетчер aiogram
|
||||
bot: Экземпляр бота
|
||||
admin_ids: ID администраторов (для защиты и уведомлений)
|
||||
channel_ids: ID каналов для проверки подписки
|
||||
enable_spam_check: Включить антиспам
|
||||
enable_subscription_check: Включить проверку подписки
|
||||
|
||||
Returns:
|
||||
dict: Словарь с экземплярами middleware для доступа к методам
|
||||
"""
|
||||
channel_ids = channel_ids or []
|
||||
|
||||
# === UPDATE LEVEL MIDDLEWARE (для всех событий) ===
|
||||
middlewares_updates = []
|
||||
instances = {}
|
||||
|
||||
# 1. Timing - замер времени (первый!)
|
||||
timing_mdw = TimingMiddleware()
|
||||
middlewares_updates.append(timing_mdw)
|
||||
instances['timing'] = timing_mdw
|
||||
|
||||
# 2. Logging - логирование всех событий
|
||||
loggings_mdw = LoggingMiddleware()
|
||||
middlewares_updates.append(loggings_mdw)
|
||||
instances['logging'] = loggings_mdw
|
||||
|
||||
# 3. ErrorHandling - обработка ошибок (последний!)
|
||||
errors_mdw = ErrorHandlingMiddleware(admin_ids=admin_ids)
|
||||
middlewares_updates.append(errors_mdw)
|
||||
instances['error'] = errors_mdw
|
||||
|
||||
# === MESSAGE LEVEL MIDDLEWARE (только для сообщений) ===
|
||||
middlewares_msg = []
|
||||
|
||||
# 1. AntiSpam - защита от флуда (опционально)
|
||||
if enable_spam_check:
|
||||
spams_mdw = AntiSpamMiddleware()
|
||||
middlewares_msg.append(spams_mdw)
|
||||
instances['spam'] = spams_mdw
|
||||
|
||||
# 2. Subscription - проверка подписки на каналы (опционально)
|
||||
if enable_subscription_check and channel_ids:
|
||||
subs_mdw = SubscriptionMiddleware(bot=bot, channels=channel_ids)
|
||||
middlewares_msg.append(subs_mdw)
|
||||
instances['subscription'] = subs_mdw
|
||||
|
||||
dp.message.middleware(BanWordsMiddleware())
|
||||
|
||||
# 3. Referral - обработка реферальных ссылок
|
||||
referral_mdw = ReferralMiddleware()
|
||||
middlewares_msg.append(referral_mdw)
|
||||
instances['referral'] = referral_mdw
|
||||
|
||||
# === РЕГИСТРАЦИЯ MIDDLEWARE ===
|
||||
|
||||
# Регистрируем update-level middleware
|
||||
for middleware in middlewares_updates:
|
||||
dp.update.middleware(middleware)
|
||||
|
||||
# Регистрируем message-level middleware
|
||||
for middleware in middlewares_msg:
|
||||
dp.message.middleware(middleware)
|
||||
|
||||
# Логируем успешную регистрацию
|
||||
enabled_features = []
|
||||
if enable_spam_check:
|
||||
enabled_features.append("AntiSpam")
|
||||
if enable_subscription_check:
|
||||
enabled_features.append("Subscription")
|
||||
|
||||
logger.info(
|
||||
text=(
|
||||
f"Middleware зарегистрированы: "
|
||||
f"Update={len(middlewares_updates)}, "
|
||||
f"Message={len(middlewares_msg)}, "
|
||||
f"Функции=[{', '.join(enabled_features) if enabled_features else 'базовые'}]"
|
||||
),
|
||||
log_type="MIDDLEWARE_SETUP"
|
||||
)
|
||||
|
||||
return instances
|
||||
337
bot/middlewares/banwords_mdw.py
Normal file
337
bot/middlewares/banwords_mdw.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Middleware для проверки сообщений на запрещённые слова (банворды).
|
||||
|
||||
Pipeline проверки:
|
||||
1. Пропускаем админов и служебные сообщения
|
||||
2. Проверяем whitelist (исключения)
|
||||
3. Проверяем режим silence (удаляем всё)
|
||||
4. Проверяем режим conflict (конфликтные слова)
|
||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
||||
6. Проверяем временные банворды
|
||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
||||
"""
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
import re
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from configs import settings
|
||||
from database import get_manager, BanWordType
|
||||
from bot.special import process_text, extract_words, get_lemma
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ("BanWordsMiddleware",)
|
||||
|
||||
|
||||
class BanWordsMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для фильтрации сообщений с банвордами.
|
||||
|
||||
Проверяет каждое текстовое сообщение на наличие запрещённых слов,
|
||||
удаляет спам и уведомляет администраторов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализирует middleware"""
|
||||
super().__init__()
|
||||
self.manager = get_manager()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Обрабатывает входящие сообщения.
|
||||
|
||||
Args:
|
||||
handler: Следующий обработчик в цепочке
|
||||
event: Сообщение от пользователя
|
||||
data: Данные из диспетчера
|
||||
|
||||
Returns:
|
||||
Any: Результат обработчика или None (если сообщение удалено)
|
||||
"""
|
||||
# Пропускаем не-текстовые сообщения
|
||||
if not event.text and not event.caption:
|
||||
return await handler(event, data)
|
||||
|
||||
# Получаем текст (из text или caption)
|
||||
message_text = event.text or event.caption
|
||||
|
||||
# Пропускаем команды (начинаются с /)
|
||||
if message_text.startswith('/'):
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем, является ли пользователь админом
|
||||
user_id = event.from_user.id
|
||||
is_super_admin = user_id in settings.OWNER_ID
|
||||
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
|
||||
|
||||
# Админы пропускаются
|
||||
if is_admin:
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем сообщение на банворды
|
||||
spam_result = await self._check_message(message_text)
|
||||
|
||||
if spam_result:
|
||||
# Найден спам - удаляем и уведомляем
|
||||
await self._handle_spam(event, spam_result)
|
||||
return None # Не продолжаем обработку
|
||||
|
||||
# Сообщение чистое - пропускаем дальше
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_for_part_check(text: str) -> str:
|
||||
"""
|
||||
Нормализует текст для проверки частей слов.
|
||||
Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
str: Нормализованный текст (только буквы и цифры, нижний регистр)
|
||||
|
||||
Examples:
|
||||
"@Astrixkeepbot" -> "astrixkeepbot"
|
||||
"hello@world.com" -> "helloworldcom"
|
||||
"test_123-456" -> "test123456"
|
||||
"""
|
||||
# Оставляем только буквы и цифры
|
||||
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
|
||||
|
||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Проверяет сообщение на наличие банвордов.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
|
||||
"""
|
||||
# Нормализуем текст для проверки
|
||||
text_lower = text.lower()
|
||||
text_processed = process_text(text_lower)
|
||||
|
||||
# === 1. WHITELIST (исключения) ===
|
||||
if self.manager.is_whitelisted(text_processed):
|
||||
logger.debug(
|
||||
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return None
|
||||
|
||||
# === 2. SILENCE MODE (удаляем всё) ===
|
||||
if await self.manager.is_silence_active():
|
||||
return {
|
||||
"word": "[режим тишины]",
|
||||
"type": "silence"
|
||||
}
|
||||
|
||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
||||
if await self.manager.is_conflict_active():
|
||||
# Проверяем конфликтные подстроки
|
||||
conflict_substring = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_SUBSTRING
|
||||
)
|
||||
for word in conflict_substring:
|
||||
if word in text_processed:
|
||||
return {"word": word, "type": "conflict_substring"}
|
||||
|
||||
# Проверяем конфликтные леммы
|
||||
conflict_lemma = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_LEMMA
|
||||
)
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
lemma = get_lemma(word_text)
|
||||
if lemma in conflict_lemma:
|
||||
return {"word": lemma, "type": "conflict_lemma"}
|
||||
|
||||
# === 4. SUBSTRING (подстроки) ===
|
||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||
for word in substring_words:
|
||||
if word in text_processed:
|
||||
return {"word": word, "type": "substring"}
|
||||
|
||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||
if part_words:
|
||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
||||
text_normalized = self._normalize_for_part_check(text)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
|
||||
for part in part_words:
|
||||
# Нормализуем само запрещенное слово тоже
|
||||
part_normalized = self._normalize_for_part_check(part)
|
||||
|
||||
if part_normalized in text_normalized:
|
||||
logger.info(
|
||||
f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') "
|
||||
f"в тексте '{text_normalized[:100]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": part, "type": "part"}
|
||||
|
||||
# === 6. LEMMA (нормальные формы слов) ===
|
||||
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
||||
if lemma_words:
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
lemma = get_lemma(word_text)
|
||||
if lemma in lemma_words:
|
||||
return {"word": lemma, "type": "lemma"}
|
||||
|
||||
# Банворды не найдены
|
||||
return None
|
||||
|
||||
async def _handle_spam(
|
||||
self,
|
||||
message: Message,
|
||||
spam_result: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает найденный спам: удаляет, логирует, уведомляет.
|
||||
|
||||
Args:
|
||||
message: Сообщение со спамом
|
||||
spam_result: Результат проверки (слово + тип)
|
||||
"""
|
||||
user = message.from_user
|
||||
matched_word = spam_result["word"]
|
||||
match_type = spam_result["type"]
|
||||
|
||||
# Получаем текст сообщения
|
||||
message_text = message.text or message.caption or "[нет текста]"
|
||||
|
||||
# === 1. УДАЛЯЕМ СООБЩЕНИЕ ===
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(
|
||||
f"Удалено сообщение от @{user.username or user.id} "
|
||||
f"(слово: '{matched_word}', тип: {match_type})",
|
||||
log_type="BANWORDS",
|
||||
message=message
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить сообщение: {e}",
|
||||
log_type="ERROR",
|
||||
message=message
|
||||
)
|
||||
return
|
||||
|
||||
# === 2. ЛОГИРУЕМ В БД ===
|
||||
await self.manager.log_spam(
|
||||
user_id=user.id,
|
||||
username=user.username or f"id{user.id}",
|
||||
chat_id=message.chat.id,
|
||||
message_text=message_text,
|
||||
matched_word=matched_word,
|
||||
match_type=match_type
|
||||
)
|
||||
|
||||
# === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
|
||||
await self._notify_admins(message, matched_word, match_type, message_text)
|
||||
|
||||
async def _notify_admins(
|
||||
self,
|
||||
message: Message,
|
||||
matched_word: str,
|
||||
match_type: str,
|
||||
message_text: str
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет уведомление в админский чат с кнопками.
|
||||
|
||||
Args:
|
||||
message: Удалённое сообщение
|
||||
matched_word: Слово, по которому сработал фильтр
|
||||
match_type: Тип проверки
|
||||
message_text: Текст сообщения
|
||||
"""
|
||||
user = message.from_user
|
||||
username = f"@{user.username}" if user.username else f"ID: {user.id}"
|
||||
|
||||
# Получаем количество предыдущих нарушений
|
||||
spam_count = await self.manager.get_user_spam_count(user.id)
|
||||
|
||||
# Формируем текст уведомления
|
||||
notification_text = (
|
||||
f"🚫 <b>Удалено сообщение</b>\n\n"
|
||||
f"👤 <b>Пользователь:</b> {username}\n"
|
||||
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
|
||||
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
|
||||
f"🔍 <b>Триггер:</b> <code>{matched_word}</code>\n"
|
||||
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {match_type}\n\n"
|
||||
f"💬 <b>Текст:</b>\n"
|
||||
f"<code>{self._escape_html(message_text[:500])}</code>"
|
||||
)
|
||||
|
||||
# Создаём клавиатуру с действиями
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔨 Забанить",
|
||||
callback_data=f"spam_ban:{user.id}:{message.chat.id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="✅ Закрыть",
|
||||
callback_data="spam_close"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"spam_stats:{user.id}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
# Отправляем уведомление
|
||||
try:
|
||||
bot = message.bot
|
||||
await bot.send_message(
|
||||
chat_id=settings.ADMIN_CHAT_ID,
|
||||
text=notification_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления админам: {e}",
|
||||
log_type="ERROR"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_type_emoji(match_type: str) -> str:
|
||||
"""Возвращает эмодзи для типа проверки"""
|
||||
emoji_map = {
|
||||
"substring": "🔤",
|
||||
"lemma": "📖",
|
||||
"part": "🧩",
|
||||
"silence": "🔇",
|
||||
"conflict_substring": "⚔️",
|
||||
"conflict_lemma": "⚔️"
|
||||
}
|
||||
return emoji_map.get(match_type, "❓")
|
||||
|
||||
@staticmethod
|
||||
def _escape_html(text: str) -> str:
|
||||
"""Экранирует HTML символы для безопасного отображения"""
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
674
bot/middlewares/error_mdw.py
Normal file
674
bot/middlewares/error_mdw.py
Normal file
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Middleware для глобальной обработки ошибок
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, List, Set
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
import traceback
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
|
||||
from aiogram.exceptions import (
|
||||
TelegramBadRequest,
|
||||
TelegramForbiddenError,
|
||||
TelegramNotFound,
|
||||
TelegramUnauthorizedError,
|
||||
TelegramRetryAfter,
|
||||
TelegramAPIError
|
||||
)
|
||||
|
||||
from middleware.loggers import logger
|
||||
from bot.utils import (
|
||||
username,
|
||||
format_content_info,
|
||||
get_content_type,
|
||||
safe_answer_callback,
|
||||
format_duration,
|
||||
format_timestamp
|
||||
)
|
||||
from bot.templates import msg
|
||||
|
||||
__all__ = ('ErrorHandlingMiddleware', 'ErrorCategory')
|
||||
|
||||
|
||||
class ErrorCategory(str, Enum):
|
||||
"""Категории ошибок"""
|
||||
TELEGRAM_API = "telegram_api" # Ошибки Telegram API
|
||||
RATE_LIMIT = "rate_limit" # Rate limiting
|
||||
PERMISSION = "permission" # Права доступа
|
||||
VALIDATION = "validation" # Валидация данных
|
||||
DATABASE = "database" # Ошибки БД
|
||||
HANDLER = "handler" # Ошибки в хендлерах
|
||||
UNKNOWN = "unknown" # Неизвестные ошибки
|
||||
|
||||
|
||||
class ErrorStats:
|
||||
"""Статистика ошибок"""
|
||||
|
||||
def __init__(self):
|
||||
# Счетчики по категориям
|
||||
self.by_category: Dict[ErrorCategory, int] = defaultdict(int)
|
||||
|
||||
# Счетчики по типам исключений
|
||||
self.by_exception: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Последние ошибки (последние 10)
|
||||
self.recent_errors: List[Dict[str, Any]] = []
|
||||
self.max_recent = 10
|
||||
|
||||
# Общая статистика
|
||||
self.total_errors: int = 0
|
||||
self.start_time: datetime = datetime.now()
|
||||
|
||||
def add_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
user_id: Optional[int] = None,
|
||||
details: Optional[Dict] = None
|
||||
):
|
||||
"""Добавляет ошибку в статистику"""
|
||||
self.total_errors += 1
|
||||
self.by_category[category] += 1
|
||||
self.by_exception[type(exception).__name__] += 1
|
||||
|
||||
# Добавляем в последние ошибки
|
||||
error_info = {
|
||||
'timestamp': datetime.now(),
|
||||
'exception': type(exception).__name__,
|
||||
'message': str(exception),
|
||||
'category': category,
|
||||
'user_id': user_id,
|
||||
'details': details or {}
|
||||
}
|
||||
|
||||
self.recent_errors.append(error_info)
|
||||
if len(self.recent_errors) > self.max_recent:
|
||||
self.recent_errors.pop(0)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Возвращает сводку по статистике"""
|
||||
uptime = datetime.now() - self.start_time
|
||||
|
||||
return {
|
||||
'total_errors': self.total_errors,
|
||||
'uptime': format_duration(int(uptime.total_seconds())),
|
||||
'by_category': dict(self.by_category),
|
||||
'by_exception': dict(self.by_exception),
|
||||
'recent_errors': self.recent_errors
|
||||
}
|
||||
|
||||
|
||||
class ErrorHandlingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для глобальной обработки ошибок.
|
||||
|
||||
Features:
|
||||
- Категоризация ошибок
|
||||
- Уведомление администраторов
|
||||
- Статистика ошибок
|
||||
- Rate limiting уведомлений
|
||||
- Retry механизм для некоторых ошибок
|
||||
- Детальное логирование
|
||||
- Graceful degradation
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
admin_ids: List[int],
|
||||
notify_admins: bool = True,
|
||||
notify_users: bool = True,
|
||||
log_errors: bool = True,
|
||||
notify_rate_limit: int = 60 # Не чаще раза в минуту для одного типа ошибки
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
admin_ids: Список ID администраторов
|
||||
notify_admins: Уведомлять администраторов
|
||||
notify_users: Уведомлять пользователей
|
||||
log_errors: Логировать ошибки
|
||||
notify_rate_limit: Минимальный интервал между уведомлениями (секунды)
|
||||
"""
|
||||
super().__init__()
|
||||
self.admin_ids = admin_ids
|
||||
self.notify_admins = notify_admins
|
||||
self.notify_users = notify_users
|
||||
self.log_errors = log_errors
|
||||
self.notify_rate_limit = notify_rate_limit
|
||||
|
||||
# Статистика
|
||||
self.stats = ErrorStats()
|
||||
|
||||
# Rate limiting для уведомлений
|
||||
# {error_type: last_notification_time}
|
||||
self._last_notifications: Dict[str, datetime] = {}
|
||||
|
||||
# Игнорируемые ошибки (для которых не нужно уведомлять)
|
||||
self.ignored_errors: Set[type] = {
|
||||
TelegramRetryAfter, # Rate limit Telegram
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Обрабатывает ошибки в хендлерах.
|
||||
|
||||
Args:
|
||||
handler: Следующий обработчик
|
||||
event: Входящее событие
|
||||
data: Контекстные данные
|
||||
|
||||
Returns:
|
||||
Результат выполнения обработчика или None при ошибке
|
||||
"""
|
||||
try:
|
||||
# Выполняем хендлер
|
||||
return await handler(event, data)
|
||||
|
||||
except Exception as e:
|
||||
# Обрабатываем ошибку
|
||||
await self._handle_error(e, event, data)
|
||||
return None
|
||||
|
||||
async def _handle_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Централизованная обработка ошибки.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
event: Событие
|
||||
data: Контекстные данные
|
||||
"""
|
||||
# Определяем категорию ошибки
|
||||
category = self._categorize_error(exception)
|
||||
|
||||
# Извлекаем информацию о событии
|
||||
event_info = self._extract_event_info(event)
|
||||
|
||||
# Добавляем в статистику
|
||||
self.stats.add_error(
|
||||
exception=exception,
|
||||
category=category,
|
||||
user_id=event_info.get('user_id'),
|
||||
details=event_info
|
||||
)
|
||||
|
||||
# Логируем ошибку
|
||||
if self.log_errors:
|
||||
await self._log_error(exception, category, event_info)
|
||||
|
||||
# Уведомляем администраторов
|
||||
if self.notify_admins and not self._is_ignored(exception):
|
||||
await self._notify_admins_about_error(exception, category, event_info, event)
|
||||
|
||||
# Уведомляем пользователя
|
||||
if self.notify_users:
|
||||
await self._notify_user_about_error(exception, category, event)
|
||||
|
||||
@staticmethod
|
||||
def _categorize_error(exception: Exception) -> ErrorCategory:
|
||||
"""
|
||||
Определяет категорию ошибки.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
|
||||
Returns:
|
||||
Категория ошибки
|
||||
"""
|
||||
# Ошибки Telegram API
|
||||
if isinstance(exception, TelegramRetryAfter):
|
||||
return ErrorCategory.RATE_LIMIT
|
||||
|
||||
if isinstance(exception, (TelegramForbiddenError, TelegramUnauthorizedError)):
|
||||
return ErrorCategory.PERMISSION
|
||||
|
||||
if isinstance(exception, (TelegramBadRequest, TelegramNotFound)):
|
||||
return ErrorCategory.TELEGRAM_API
|
||||
|
||||
if isinstance(exception, TelegramAPIError):
|
||||
return ErrorCategory.TELEGRAM_API
|
||||
|
||||
# Ошибки валидации
|
||||
if isinstance(exception, (ValueError, TypeError, AttributeError)):
|
||||
return ErrorCategory.VALIDATION
|
||||
|
||||
# Ошибки БД (примеры, замени на свои)
|
||||
# if isinstance(exception, (DatabaseError, OperationalError)):
|
||||
# return ErrorCategory.DATABASE
|
||||
|
||||
# Остальные ошибки
|
||||
return ErrorCategory.HANDLER
|
||||
|
||||
@staticmethod
|
||||
def _extract_event_info(event: TelegramObject) -> Dict[str, Any]:
|
||||
"""
|
||||
Извлекает информацию о событии.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
Словарь с информацией
|
||||
"""
|
||||
info: Dict[str, Any] = {
|
||||
'event_type': type(event).__name__,
|
||||
'timestamp': datetime.now(),
|
||||
'user_str': '@System',
|
||||
'user_id': None,
|
||||
'chat_id': None,
|
||||
'chat_type': None,
|
||||
'message_id': None,
|
||||
'content_type': None,
|
||||
'content_info': None,
|
||||
'text': None
|
||||
}
|
||||
|
||||
# Обработка разных типов событий
|
||||
message = None
|
||||
|
||||
if isinstance(event, Message):
|
||||
message = event
|
||||
elif isinstance(event, CallbackQuery):
|
||||
message = event.message
|
||||
info['callback_data'] = event.data
|
||||
elif isinstance(event, Update):
|
||||
message = (
|
||||
event.message or
|
||||
event.edited_message or
|
||||
event.channel_post or
|
||||
event.edited_channel_post
|
||||
)
|
||||
|
||||
if event.callback_query:
|
||||
info['callback_data'] = event.callback_query.data
|
||||
|
||||
# Извлекаем информацию из сообщения
|
||||
if message:
|
||||
# Пользователь
|
||||
if message.from_user:
|
||||
info['user_str'] = username(message)
|
||||
info['user_id'] = message.from_user.id
|
||||
|
||||
# Чат
|
||||
info['chat_id'] = message.chat.id
|
||||
info['chat_type'] = message.chat.type
|
||||
info['message_id'] = message.message_id
|
||||
|
||||
# Контент
|
||||
info['content_type'] = get_content_type(message)
|
||||
info['content_info'] = format_content_info(message, include_text=False)
|
||||
|
||||
# Текст
|
||||
if message.text:
|
||||
text = message.text
|
||||
info['text'] = text if len(text) <= 100 else text[:100] + "..."
|
||||
elif message.caption:
|
||||
caption = message.caption
|
||||
info['caption'] = caption if len(caption) <= 100 else caption[:100] + "..."
|
||||
|
||||
return info
|
||||
|
||||
@staticmethod
|
||||
async def _log_error(
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Логирует ошибку.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event_info: Информация о событии
|
||||
"""
|
||||
# Формируем сообщение для лога
|
||||
error_type = type(exception).__name__
|
||||
error_msg = str(exception)
|
||||
|
||||
# Получаем traceback
|
||||
tb = ''.join(traceback.format_exception(
|
||||
type(exception),
|
||||
exception,
|
||||
exception.__traceback__
|
||||
))
|
||||
|
||||
# Базовое сообщение
|
||||
log_msg = (
|
||||
f"🚨 Ошибка в хендлере\n"
|
||||
f"├─ Тип: {error_type}\n"
|
||||
f"├─ Категория: {category.value}\n"
|
||||
f"├─ Сообщение: {error_msg}\n"
|
||||
f"├─ Событие: {event_info['event_type']}\n"
|
||||
)
|
||||
|
||||
if event_info.get('text'):
|
||||
log_msg += f"├─ Текст: {event_info['text']}\n"
|
||||
|
||||
if event_info.get('callback_data'):
|
||||
log_msg += f"├─ Callback: {event_info['callback_data']}\n"
|
||||
|
||||
if event_info.get('content_info'):
|
||||
log_msg += f"└─ Контент: {event_info['content_info']}"
|
||||
|
||||
# Логируем с полным traceback
|
||||
logger.error(
|
||||
text=log_msg,
|
||||
log_type=f"ERROR_{category.value.upper()}",
|
||||
user=event_info['user_str'],
|
||||
)
|
||||
|
||||
# Дополнительно логируем traceback отдельно для детального анализа
|
||||
logger.debug(
|
||||
text=f"Полный traceback:\n{tb}",
|
||||
log_type=f"ERROR_{category.value.upper()}_TRACEBACK",
|
||||
user=event_info['user_str']
|
||||
)
|
||||
|
||||
async def _notify_admins_about_error(
|
||||
self,
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event_info: Dict[str, Any],
|
||||
event: TelegramObject
|
||||
):
|
||||
"""
|
||||
Уведомляет администраторов об ошибке.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event_info: Информация о событии
|
||||
event: Объект события
|
||||
"""
|
||||
# Проверяем rate limit
|
||||
error_key = type(exception).__name__
|
||||
|
||||
if not self._should_notify(error_key):
|
||||
logger.debug(
|
||||
f"Пропуск уведомления админов о {error_key} (rate limit)",
|
||||
log_type="ADMIN_NOTIFY_SKIP"
|
||||
)
|
||||
return
|
||||
|
||||
# Обновляем время последнего уведомления
|
||||
self._last_notifications[error_key] = datetime.now()
|
||||
|
||||
# Получаем bot
|
||||
bot = event.bot if hasattr(event, 'bot') else None
|
||||
if not bot:
|
||||
return
|
||||
|
||||
# Формируем сообщение
|
||||
error_type = type(exception).__name__
|
||||
error_msg = str(exception)
|
||||
|
||||
# Определяем emoji для категории
|
||||
category_emoji = self._get_category_emoji(category)
|
||||
|
||||
notification = (
|
||||
f"{category_emoji} <b>Ошибка в боте</b>\n\n"
|
||||
f"📊 <b>Информация:</b>\n"
|
||||
f"├─ Тип: <code>{error_type}</code>\n"
|
||||
f"├─ Категория: <code>{category.value}</code>\n"
|
||||
f"├─ Время: {format_timestamp(datetime.now())}\n"
|
||||
)
|
||||
|
||||
# Добавляем информацию о пользователе
|
||||
if event_info.get('user_str') and event_info['user_str'] != '@System':
|
||||
notification += f"└─ Пользователь: {event_info['user_str']}\n\n"
|
||||
else:
|
||||
notification += "\n"
|
||||
|
||||
# Добавляем сообщение ошибки
|
||||
if len(error_msg) <= 200:
|
||||
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg}</code>\n\n"
|
||||
else:
|
||||
notification += f"💬 <b>Сообщение:</b>\n<code>{error_msg[:200]}...</code>\n\n"
|
||||
|
||||
# Добавляем контекст события
|
||||
notification += f"📋 <b>Контекст:</b>\n"
|
||||
|
||||
if event_info.get('text'):
|
||||
notification += f"├─ Текст: <code>{event_info['text']}</code>\n"
|
||||
|
||||
if event_info.get('callback_data'):
|
||||
notification += f"├─ Callback: <code>{event_info['callback_data']}</code>\n"
|
||||
|
||||
if event_info.get('content_info'):
|
||||
notification += f"├─ Контент: {event_info['content_info']}\n"
|
||||
|
||||
if event_info.get('chat_type'):
|
||||
notification += f"└─ Тип чата: <code>{event_info['chat_type']}</code>\n"
|
||||
|
||||
# Добавляем статистику
|
||||
stats = self.stats.get_summary()
|
||||
notification += (
|
||||
f"\n📊 <b>Статистика:</b>\n"
|
||||
f"└─ Всего ошибок: {stats['total_errors']}"
|
||||
)
|
||||
|
||||
# Отправляем администраторам
|
||||
for admin_id in self.admin_ids:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=notification,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Администратор {admin_id} уведомлен об ошибке",
|
||||
log_type="ADMIN_NOTIFIED"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось уведомить админа {admin_id}: {e}",
|
||||
log_type="ADMIN_NOTIFY_ERROR"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _notify_user_about_error(
|
||||
exception: Exception,
|
||||
category: ErrorCategory,
|
||||
event: TelegramObject
|
||||
):
|
||||
"""
|
||||
Уведомляет пользователя об ошибке.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
category: Категория ошибки
|
||||
event: Объект события
|
||||
"""
|
||||
# Формируем сообщение в зависимости от категории
|
||||
error_messages = {
|
||||
ErrorCategory.TELEGRAM_API: (
|
||||
"⚠️ Произошла техническая ошибка.\n"
|
||||
"Попробуйте повторить действие."
|
||||
),
|
||||
ErrorCategory.RATE_LIMIT: (
|
||||
"⏳ Слишком много запросов.\n"
|
||||
"Пожалуйста, подождите немного."
|
||||
),
|
||||
ErrorCategory.PERMISSION: (
|
||||
"🔒 Недостаточно прав для выполнения действия."
|
||||
),
|
||||
ErrorCategory.VALIDATION: (
|
||||
"❌ Некорректные данные.\n"
|
||||
"Проверьте правильность ввода."
|
||||
),
|
||||
ErrorCategory.DATABASE: (
|
||||
"💾 Ошибка базы данных.\n"
|
||||
"Попробуйте позже."
|
||||
),
|
||||
ErrorCategory.HANDLER: (
|
||||
"⚠️ Произошла непредвиденная ошибка.\n"
|
||||
"Разработчики уже уведомлены."
|
||||
),
|
||||
ErrorCategory.UNKNOWN: (
|
||||
"⚠️ Произошла ошибка.\n"
|
||||
"Попробуйте повторить позже."
|
||||
)
|
||||
}
|
||||
|
||||
error_text = error_messages.get(
|
||||
category,
|
||||
error_messages[ErrorCategory.UNKNOWN]
|
||||
)
|
||||
|
||||
error_text += "\n\nПопробуйте нажать /start или обратитесь к администратору."
|
||||
|
||||
try:
|
||||
# Отправляем сообщение
|
||||
if isinstance(event, Message):
|
||||
await msg(event, text=error_text)
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await safe_answer_callback(event, error_text[:200], show_alert=True)
|
||||
|
||||
# Также отправляем в чат если сообщение доступно
|
||||
if event.message:
|
||||
try:
|
||||
await msg(event.message, text=error_text)
|
||||
except:
|
||||
pass
|
||||
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
await msg(event.message, text=error_text)
|
||||
elif event.callback_query:
|
||||
await safe_answer_callback(
|
||||
event.callback_query,
|
||||
error_text[:200],
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Пользователь уведомлен об ошибке",
|
||||
log_type="USER_ERROR_NOTIFIED"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось уведомить пользователя об ошибке: {e}",
|
||||
log_type="USER_NOTIFY_ERROR"
|
||||
)
|
||||
|
||||
def _should_notify(self, error_key: str) -> bool:
|
||||
"""
|
||||
Проверяет, нужно ли отправлять уведомление (rate limiting).
|
||||
|
||||
Args:
|
||||
error_key: Ключ ошибки
|
||||
|
||||
Returns:
|
||||
True если можно отправить уведомление
|
||||
"""
|
||||
if error_key not in self._last_notifications:
|
||||
return True
|
||||
|
||||
last_time = self._last_notifications[error_key]
|
||||
time_passed = (datetime.now() - last_time).total_seconds()
|
||||
|
||||
return time_passed >= self.notify_rate_limit
|
||||
|
||||
def _is_ignored(self, exception: Exception) -> bool:
|
||||
"""
|
||||
Проверяет, игнорируется ли ошибка.
|
||||
|
||||
Args:
|
||||
exception: Исключение
|
||||
|
||||
Returns:
|
||||
True если ошибка игнорируется
|
||||
"""
|
||||
return type(exception) in self.ignored_errors
|
||||
|
||||
@staticmethod
|
||||
def _get_category_emoji(category: ErrorCategory) -> str:
|
||||
"""Возвращает emoji для категории ошибки"""
|
||||
emoji_map = {
|
||||
ErrorCategory.TELEGRAM_API: "🔌",
|
||||
ErrorCategory.RATE_LIMIT: "⏳",
|
||||
ErrorCategory.PERMISSION: "🔒",
|
||||
ErrorCategory.VALIDATION: "❌",
|
||||
ErrorCategory.DATABASE: "💾",
|
||||
ErrorCategory.HANDLER: "🚨",
|
||||
ErrorCategory.UNKNOWN: "⚠️"
|
||||
}
|
||||
|
||||
return emoji_map.get(category, "⚠️")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Возвращает статистику ошибок"""
|
||||
return self.stats.get_summary()
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сбрасывает статистику"""
|
||||
self.stats = ErrorStats()
|
||||
|
||||
def add_ignored_error(self, error_type: type):
|
||||
"""Добавляет тип ошибки в игнорируемые"""
|
||||
self.ignored_errors.add(error_type)
|
||||
|
||||
def remove_ignored_error(self, error_type: type):
|
||||
"""Удаляет тип ошибки из игнорируемых"""
|
||||
self.ignored_errors.discard(error_type)
|
||||
|
||||
|
||||
# ================= УТИЛИТЫ =================
|
||||
|
||||
def format_error_stats(stats: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Форматирует статистику ошибок.
|
||||
|
||||
Args:
|
||||
stats: Словарь со статистикой
|
||||
|
||||
Returns:
|
||||
Отформатированная строка
|
||||
|
||||
Example:
|
||||
>> stats = middleware.get_stats()
|
||||
>> print(format_error_stats(stats))
|
||||
"""
|
||||
text = (
|
||||
f"🚨 <b>Статистика ошибок</b>\n\n"
|
||||
f"📊 <b>Общая информация:</b>\n"
|
||||
f"├─ Всего ошибок: {stats['total_errors']}\n"
|
||||
f"└─ Время работы: {stats['uptime']}\n\n"
|
||||
)
|
||||
|
||||
# По категориям
|
||||
if stats['by_category']:
|
||||
text += f"📁 <b>По категориям:</b>\n"
|
||||
for category, count in stats['by_category'].items():
|
||||
text += f"├─ {category}: {count}\n"
|
||||
text += "\n"
|
||||
|
||||
# По типам исключений
|
||||
if stats['by_exception']:
|
||||
text += f"🔧 <b>По типам (топ-5):</b>\n"
|
||||
sorted_exceptions = sorted(
|
||||
stats['by_exception'].items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:5]
|
||||
|
||||
for exc_type, count in sorted_exceptions:
|
||||
text += f"├─ {exc_type}: {count}\n"
|
||||
|
||||
return text
|
||||
350
bot/middlewares/logging_mdw.py
Normal file
350
bot/middlewares/logging_mdw.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Middleware для логирования всех событий бота
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import (
|
||||
TelegramObject,
|
||||
Update,
|
||||
Message,
|
||||
CallbackQuery,
|
||||
InlineQuery,
|
||||
ChatMemberUpdated
|
||||
)
|
||||
|
||||
from middleware.loggers import logger
|
||||
from ..utils import (
|
||||
username,
|
||||
get_content_type,
|
||||
is_command,
|
||||
parse_command,
|
||||
is_group_chat
|
||||
)
|
||||
|
||||
__all__ = ('LoggingMiddleware',)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для детального логирования всех событий бота.
|
||||
|
||||
Типы логов:
|
||||
- CMD: Команды бота
|
||||
- MSG: Текстовые сообщения
|
||||
- MEDIA: Медиа сообщения
|
||||
- CBD: Callback queries
|
||||
- INLINE: Inline queries
|
||||
- MEMBER: Изменения участников чата
|
||||
"""
|
||||
|
||||
def __init__(self, project_prefix: str = "PRIMO"):
|
||||
super().__init__()
|
||||
self.project_prefix = project_prefix
|
||||
|
||||
# Статистика
|
||||
self.stats = {
|
||||
'total': 0,
|
||||
'commands': 0,
|
||||
'messages': 0,
|
||||
'callbacks': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""Обрабатывает входящее событие"""
|
||||
self.stats['total'] += 1
|
||||
start_time = datetime.now()
|
||||
|
||||
# Анализируем событие
|
||||
log_info = self._analyze_event(event)
|
||||
|
||||
if not log_info:
|
||||
return await handler(event, data)
|
||||
|
||||
log_type, log_text, user_str = log_info
|
||||
|
||||
# Добавляем префикс проекта
|
||||
prefixed_log_type = f"{self.project_prefix}-{log_type}"
|
||||
|
||||
# Логируем получение события
|
||||
logger.info(text=log_text, log_type=prefixed_log_type, user=user_str)
|
||||
|
||||
try:
|
||||
# Выполняем обработчик
|
||||
result = await handler(event, data)
|
||||
|
||||
# Вычисляем время обработки
|
||||
processing_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# Логируем успешное выполнение для команд
|
||||
if log_type == "CMD":
|
||||
self.stats['commands'] += 1
|
||||
logger.debug(
|
||||
text=f"✅ Команда обработана за {processing_time:.3f}s",
|
||||
log_type=prefixed_log_type,
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.stats['errors'] += 1
|
||||
logger.error(
|
||||
text=f"❌ Ошибка обработки: {str(e)}",
|
||||
log_type=prefixed_log_type,
|
||||
user=user_str,
|
||||
)
|
||||
raise
|
||||
|
||||
def _analyze_event(self, event: TelegramObject) -> Optional[Tuple[str, str, str]]:
|
||||
"""
|
||||
Анализирует событие и извлекает информацию для логирования.
|
||||
|
||||
Returns:
|
||||
Tuple: (тип_лога, текст_лога, пользователь) или None
|
||||
"""
|
||||
if isinstance(event, Update):
|
||||
return self._analyze_update(event)
|
||||
elif isinstance(event, Message):
|
||||
return self._analyze_message(event)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return self._analyze_callback(event)
|
||||
elif isinstance(event, InlineQuery):
|
||||
return self._analyze_inline_query(event)
|
||||
elif isinstance(event, ChatMemberUpdated):
|
||||
return self._analyze_member_update(event)
|
||||
|
||||
return None
|
||||
|
||||
def _analyze_update(self, update: Update) -> Optional[Tuple[str, str, str]]:
|
||||
"""Анализирует Update объект"""
|
||||
if update.message:
|
||||
return self._analyze_message(update.message)
|
||||
elif update.edited_message:
|
||||
result = self._analyze_message(update.edited_message)
|
||||
if result:
|
||||
log_type, log_text, user_str = result
|
||||
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
|
||||
return log_type, log_text, user_str
|
||||
elif update.channel_post:
|
||||
return self._analyze_message(update.channel_post, is_channel=True)
|
||||
elif update.edited_channel_post:
|
||||
result = self._analyze_message(update.edited_channel_post, is_channel=True)
|
||||
if result:
|
||||
log_type, log_text, user_str = result
|
||||
log_text = f"✏️ [РЕДАКТИРОВАНО] {log_text}"
|
||||
return log_type, log_text, user_str
|
||||
elif update.callback_query:
|
||||
return self._analyze_callback(update.callback_query)
|
||||
elif update.inline_query:
|
||||
return self._analyze_inline_query(update.inline_query)
|
||||
elif update.my_chat_member:
|
||||
return self._analyze_member_update(update.my_chat_member)
|
||||
elif update.chat_member:
|
||||
return self._analyze_member_update(update.chat_member)
|
||||
|
||||
return None
|
||||
|
||||
def _analyze_message(self, message: Message, is_channel: bool = False) -> Tuple[str, str, str]:
|
||||
"""Анализирует сообщение"""
|
||||
user_str = username(message)
|
||||
|
||||
# Формируем префикс с информацией о чате
|
||||
chat_info = ""
|
||||
if is_group_chat(message):
|
||||
chat_info = f"[{message.chat.type.upper()} {message.chat.id}] "
|
||||
elif is_channel:
|
||||
chat_info = f"[CHANNEL {message.chat.id}] "
|
||||
else:
|
||||
chat_info = f"[PM {message.chat.id}] "
|
||||
|
||||
# Проверяем команду
|
||||
if message.text and is_command(message.text):
|
||||
self.stats['messages'] += 1
|
||||
parsed = parse_command(message.text)
|
||||
|
||||
if parsed:
|
||||
log_text = f"{chat_info}📝 Команда: /{parsed.command}"
|
||||
|
||||
if parsed.args:
|
||||
args_str = ' '.join(parsed.args[:3])
|
||||
if len(parsed.args) > 3:
|
||||
args_str += f" ... (+{len(parsed.args) - 3})"
|
||||
log_text += f" | Аргументы: {args_str}"
|
||||
|
||||
if parsed.flags:
|
||||
flags_str = ', '.join(f"--{k}" for k in list(parsed.flags.keys())[:3])
|
||||
if len(parsed.flags) > 3:
|
||||
flags_str += f" ... (+{len(parsed.flags) - 3})"
|
||||
log_text += f" | Флаги: {flags_str}"
|
||||
|
||||
return "CMD", log_text, user_str
|
||||
|
||||
# Обычное сообщение
|
||||
self.stats['messages'] += 1
|
||||
|
||||
content_type = get_content_type(message, russian=True)
|
||||
content_emoji = self._get_content_emoji(message)
|
||||
|
||||
# Текстовое сообщение
|
||||
if message.text:
|
||||
text_preview = message.text
|
||||
if len(text_preview) > 100:
|
||||
text_preview = text_preview[:100] + "..."
|
||||
|
||||
log_text = f"{chat_info}{content_emoji} Сообщение ({len(message.text)} симв.): {text_preview!r}"
|
||||
|
||||
# Медиа с caption
|
||||
elif message.caption:
|
||||
caption_preview = message.caption
|
||||
if len(caption_preview) > 50:
|
||||
caption_preview = caption_preview[:50] + "..."
|
||||
|
||||
log_text = f"{chat_info}{content_emoji} {content_type}"
|
||||
|
||||
# Добавляем детали медиа
|
||||
media_details = self._get_media_details_str(message)
|
||||
if media_details:
|
||||
log_text += f" {media_details}"
|
||||
|
||||
log_text += f" | Описание: {caption_preview!r}"
|
||||
|
||||
# Медиа без caption
|
||||
else:
|
||||
log_text = f"{chat_info}{content_emoji} {content_type}"
|
||||
|
||||
media_details = self._get_media_details_str(message)
|
||||
if media_details:
|
||||
log_text += f" {media_details}"
|
||||
|
||||
# Определяем тип лога
|
||||
log_type = "MEDIA" if message.content_type != "text" else "MSG"
|
||||
|
||||
# Добавляем префикс канала
|
||||
if is_channel:
|
||||
log_text = f"📢 {log_text}"
|
||||
|
||||
return log_type, log_text, user_str
|
||||
|
||||
def _analyze_callback(self, callback: CallbackQuery) -> Tuple[str, str, str]:
|
||||
"""Анализирует callback query"""
|
||||
self.stats['callbacks'] += 1
|
||||
|
||||
user_str = f"@{callback.from_user.username}" if callback.from_user.username else f"id{callback.from_user.id}"
|
||||
|
||||
callback_data = callback.data or "None"
|
||||
if len(callback_data) > 50:
|
||||
callback_data = callback_data[:50] + "..."
|
||||
|
||||
chat_info = f"[MSG {callback.message.message_id}] " if callback.message else ""
|
||||
log_text = f"{chat_info}🔘 Callback: {callback_data!r}"
|
||||
|
||||
return "CBD", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _analyze_inline_query(inline_query: InlineQuery) -> Tuple[str, str, str]:
|
||||
"""Анализирует inline query"""
|
||||
user_str = f"@{inline_query.from_user.username}" if inline_query.from_user.username else f"id{inline_query.from_user.id}"
|
||||
|
||||
query = inline_query.query or ""
|
||||
if len(query) > 50:
|
||||
query = query[:50] + "..."
|
||||
|
||||
log_text = f"🔍 Inline запрос: {query!r}"
|
||||
|
||||
return "INLINE", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _analyze_member_update(update: ChatMemberUpdated) -> Tuple[str, str, str]:
|
||||
"""Анализирует изменения участников"""
|
||||
user_str = f"@{update.from_user.username}" if update.from_user.username else f"id{update.from_user.id}"
|
||||
|
||||
old_status = update.old_chat_member.status
|
||||
new_status = update.new_chat_member.status
|
||||
|
||||
chat_info = f"[{update.chat.type.upper()} {update.chat.id}] "
|
||||
log_text = f"{chat_info}👥 Изменение статуса: {old_status} → {new_status}"
|
||||
|
||||
return "MEMBER", log_text, user_str
|
||||
|
||||
@staticmethod
|
||||
def _get_content_emoji(message: Message) -> str:
|
||||
"""Возвращает emoji для типа контента"""
|
||||
emoji_map = {
|
||||
'text': '💬',
|
||||
'photo': '📷',
|
||||
'video': '🎥',
|
||||
'animation': '🎞️',
|
||||
'audio': '🎵',
|
||||
'voice': '🎤',
|
||||
'video_note': '🎬',
|
||||
'document': '📄',
|
||||
'sticker': '🎨',
|
||||
'location': '📍',
|
||||
'contact': '👤',
|
||||
'poll': '📊',
|
||||
'dice': '🎲'
|
||||
}
|
||||
|
||||
return emoji_map.get(message.content_type, '📎')
|
||||
|
||||
@staticmethod
|
||||
def _get_media_details_str(message: Message) -> Optional[str]:
|
||||
"""Возвращает строку с деталями медиа файла"""
|
||||
from ..utils import get_media_info
|
||||
|
||||
try:
|
||||
media_info = get_media_info(message)
|
||||
details = []
|
||||
|
||||
# Размер файла
|
||||
if 'file_size_mb' in media_info:
|
||||
details.append(f"{media_info['file_size_mb']} MB")
|
||||
elif 'file_size_kb' in media_info:
|
||||
details.append(f"{media_info['file_size_kb']} KB")
|
||||
|
||||
# Длительность
|
||||
if 'duration_formatted' in media_info:
|
||||
details.append(media_info['duration_formatted'])
|
||||
|
||||
# Разрешение
|
||||
if 'width' in media_info and 'height' in media_info:
|
||||
details.append(f"{media_info['width']}x{media_info['height']}")
|
||||
|
||||
return f"({', '.join(details)})" if details else None
|
||||
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Возвращает статистику middleware"""
|
||||
return self.stats.copy()
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сбрасывает статистику"""
|
||||
self.stats = {
|
||||
'total': 0,
|
||||
'commands': 0,
|
||||
'messages': 0,
|
||||
'callbacks': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
|
||||
def format_log_stats(stats: Dict[str, int]) -> str:
|
||||
"""Форматирует статистику для вывода"""
|
||||
return (
|
||||
f"📊 Статистика логирования:\n"
|
||||
f"├─ 📨 Всего событий: {stats['total']}\n"
|
||||
f"├─ 📝 Команд: {stats['commands']}\n"
|
||||
f"├─ 💬 Сообщений: {stats['messages']}\n"
|
||||
f"├─ 🔘 Callbacks: {stats['callbacks']}\n"
|
||||
f"└─ ❌ Ошибок: {stats['errors']}"
|
||||
)
|
||||
544
bot/middlewares/referal_mdw.py
Normal file
544
bot/middlewares/referal_mdw.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Middleware для обработки реферальных ссылок и deep links
|
||||
"""
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import re
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.filters.command import CommandObject
|
||||
from aiogram.types import TelegramObject, Message, User
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'ReferralMiddleware',
|
||||
'DeepLinkData',
|
||||
'referral_stats',
|
||||
'ReferralType'
|
||||
)
|
||||
|
||||
|
||||
class ReferralType:
|
||||
"""Типы реферальных ссылок"""
|
||||
REFERRAL = 'ref' # Обычная реферальная ссылка
|
||||
PROMO = 'promo' # Промокод
|
||||
UTM = 'utm' # UTM метки
|
||||
INVITE = 'invite' # Инвайт-ссылка
|
||||
DEEPLINK = 'deeplink' # Произвольный deep link
|
||||
CUSTOM = 'custom' # Кастомный тип
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeepLinkData:
|
||||
"""
|
||||
Данные deep link.
|
||||
|
||||
Attributes:
|
||||
raw: Исходная строка (все после /start)
|
||||
type: Тип ссылки (ref, promo, utm, и т.д.)
|
||||
params: Распарсенные параметры
|
||||
user_id: ID пользователя, перешедшего по ссылке
|
||||
username: Username пользователя
|
||||
timestamp: Время перехода
|
||||
is_valid: Валидна ли ссылка
|
||||
"""
|
||||
raw: str
|
||||
type: str = ReferralType.DEEPLINK
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
user_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
is_valid: bool = True
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Получает параметр по ключу"""
|
||||
return self.params.get(key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Позволяет использовать data['key']"""
|
||||
return self.params[key]
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Позволяет использовать 'key' in data"""
|
||||
return key in self.params
|
||||
|
||||
|
||||
class ReferralStatistics:
|
||||
"""
|
||||
Статистика реферальных переходов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Счетчики переходов по типам: {type: count}
|
||||
self.clicks_by_type: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Переходы по кодам: {ref_code: count}
|
||||
self.clicks_by_code: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# История переходов: [(timestamp, user_id, ref_code, type), ...]
|
||||
self.history: list[tuple[datetime, int, str, str]] = []
|
||||
|
||||
# Уникальные пользователи: {ref_code: set(user_ids)}
|
||||
self.unique_users: Dict[str, set[int]] = defaultdict(set)
|
||||
|
||||
def record(self, deep_link: DeepLinkData) -> None:
|
||||
"""Записывает переход"""
|
||||
# Счетчик по типу
|
||||
self.clicks_by_type[deep_link.type] += 1
|
||||
|
||||
# Счетчик по коду (если есть реферальный код)
|
||||
ref_code = deep_link.get('ref_code') or deep_link.get('code') or deep_link.raw
|
||||
if ref_code:
|
||||
self.clicks_by_code[ref_code] += 1
|
||||
|
||||
# Уникальные пользователи
|
||||
if deep_link.user_id:
|
||||
self.unique_users[ref_code].add(deep_link.user_id)
|
||||
|
||||
# История
|
||||
if deep_link.user_id:
|
||||
self.history.append((
|
||||
deep_link.timestamp,
|
||||
deep_link.user_id,
|
||||
ref_code,
|
||||
deep_link.type
|
||||
))
|
||||
|
||||
def get_stats(self, ref_code: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает статистику.
|
||||
|
||||
Args:
|
||||
ref_code: Код для фильтрации (если None, возвращает общую статистику)
|
||||
"""
|
||||
if ref_code:
|
||||
return {
|
||||
'ref_code': ref_code,
|
||||
'total_clicks': self.clicks_by_code.get(ref_code, 0),
|
||||
'unique_users': len(self.unique_users.get(ref_code, set()))
|
||||
}
|
||||
|
||||
return {
|
||||
'total_clicks': sum(self.clicks_by_type.values()),
|
||||
'clicks_by_type': dict(self.clicks_by_type),
|
||||
'top_codes': self.get_top_codes(10),
|
||||
'total_unique_users': sum(len(users) for users in self.unique_users.values())
|
||||
}
|
||||
|
||||
def get_top_codes(self, limit: int = 10) -> list[tuple[str, int]]:
|
||||
"""Возвращает топ реферальных кодов"""
|
||||
sorted_codes = sorted(
|
||||
self.clicks_by_code.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
return sorted_codes[:limit]
|
||||
|
||||
|
||||
# Глобальная статистика
|
||||
referral_stats = ReferralStatistics()
|
||||
|
||||
|
||||
class ReferralMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для обработки реферальных ссылок и deep links.
|
||||
|
||||
Возможности:
|
||||
- Парсинг различных форматов deep links
|
||||
- Автоматическое определение типа ссылки
|
||||
- Валидация параметров
|
||||
- Сбор статистики
|
||||
- Интеграция с базой данных через callback
|
||||
- Поддержка сложных параметров (ref_123_promo_abc)
|
||||
|
||||
Поддерживаемые форматы:
|
||||
- /start ref123 → {'ref_code': 'ref123'}
|
||||
- /start promo_SUMMER2024 → {'type': 'promo', 'code': 'SUMMER2024'}
|
||||
- /start ref_123_bonus_50 → {'ref_code': '123', 'bonus': '50'}
|
||||
- /start utm_source_telegram → {'utm_source': 'telegram'}
|
||||
|
||||
Attributes:
|
||||
on_referral: Callback функция для сохранения в БД
|
||||
validator: Функция валидации кодов
|
||||
parse_complex: Парсить ли сложные параметры
|
||||
collect_stats: Собирать ли статистику
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.referral import ReferralMiddleware, DeepLinkData
|
||||
|
||||
async def save_referral(deep_link: DeepLinkData):
|
||||
# Сохранение в БД
|
||||
await db.save_referral(
|
||||
user_id=deep_link.user_id,
|
||||
ref_code=deep_link.get('ref_code'),
|
||||
timestamp=deep_link.timestamp
|
||||
)
|
||||
|
||||
# Регистрация middleware
|
||||
referral_mdw = ReferralMiddleware(
|
||||
on_referral=save_referral,
|
||||
parse_complex=True,
|
||||
collect_stats=True
|
||||
)
|
||||
|
||||
dp.message.middleware(referral_mdw)
|
||||
|
||||
# В хендлере
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message, deep_link: Optional[DeepLinkData] = None):
|
||||
if deep_link:
|
||||
ref_code = deep_link.get('ref_code')
|
||||
await message.answer(f"Привет! Вы пришли по ссылке: {ref_code}")
|
||||
else:
|
||||
await message.answer("Привет!")
|
||||
```
|
||||
"""
|
||||
|
||||
# Паттерны для парсинга
|
||||
PATTERNS = {
|
||||
# ref_123 или ref123
|
||||
ReferralType.REFERRAL: re.compile(r'^ref[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# promo_SUMMER2024
|
||||
ReferralType.PROMO: re.compile(r'^promo[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# invite_abc123
|
||||
ReferralType.INVITE: re.compile(r'^invite[_-]?(\w+)$', re.IGNORECASE),
|
||||
|
||||
# utm_source_telegram_campaign_ads
|
||||
ReferralType.UTM: re.compile(r'^utm[_-]', re.IGNORECASE),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_referral: Optional[Callable[[DeepLinkData], Awaitable[None]]] = None,
|
||||
validator: Optional[Callable[[str], bool]] = None,
|
||||
parse_complex: bool = True,
|
||||
collect_stats: bool = True,
|
||||
max_length: int = 64
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
on_referral: Callback для обработки реферала (сохранение в БД)
|
||||
validator: Функция валидации кода (должна вернуть True если валиден)
|
||||
parse_complex: Парсить ли сложные параметры (ref_123_bonus_50)
|
||||
collect_stats: Собирать ли статистику
|
||||
max_length: Максимальная длина deep link
|
||||
"""
|
||||
super().__init__()
|
||||
self.on_referral = on_referral
|
||||
self.validator = validator
|
||||
self.parse_complex = parse_complex
|
||||
self.collect_stats = collect_stats
|
||||
self.max_length = max_length
|
||||
|
||||
def _parse_simple(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Парсит простые форматы deep links.
|
||||
|
||||
Args:
|
||||
args: Аргументы команды /start
|
||||
|
||||
Returns:
|
||||
tuple: (тип, параметры)
|
||||
"""
|
||||
# Проверка по паттернам
|
||||
for link_type, pattern in self.PATTERNS.items():
|
||||
match = pattern.match(args)
|
||||
if match:
|
||||
if link_type == ReferralType.REFERRAL:
|
||||
return link_type, {'ref_code': match.group(1)}
|
||||
elif link_type == ReferralType.PROMO:
|
||||
return link_type, {'code': match.group(1), 'promo_code': match.group(1)}
|
||||
elif link_type == ReferralType.INVITE:
|
||||
return link_type, {'invite_code': match.group(1)}
|
||||
elif link_type == ReferralType.UTM:
|
||||
# Парсим UTM параметры
|
||||
return link_type, self._parse_utm(args)
|
||||
|
||||
# Если не совпало ни с одним паттерном - просто код
|
||||
return ReferralType.DEEPLINK, {'code': args}
|
||||
|
||||
def _parse_utm(self, args: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Парсит UTM параметры: utm_source_telegram_campaign_ads
|
||||
|
||||
Args:
|
||||
args: Строка с UTM параметрами
|
||||
|
||||
Returns:
|
||||
Dict с UTM параметрами
|
||||
"""
|
||||
params = {}
|
||||
|
||||
# Удаляем префикс utm_
|
||||
if args.lower().startswith('utm_'):
|
||||
args = args[4:]
|
||||
|
||||
# Разбиваем по _ и парсим пары ключ-значение
|
||||
parts = args.split('_')
|
||||
|
||||
i = 0
|
||||
while i < len(parts) - 1:
|
||||
key = f"utm_{parts[i]}"
|
||||
value = parts[i + 1]
|
||||
params[key] = value
|
||||
i += 2
|
||||
|
||||
return params
|
||||
|
||||
def _parse_complex(self, args: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Парсит сложные форматы: ref_123_bonus_50_promo_SUMMER
|
||||
|
||||
Args:
|
||||
args: Аргументы команды
|
||||
|
||||
Returns:
|
||||
tuple: (тип, параметры)
|
||||
"""
|
||||
params = {}
|
||||
parts = args.split('_')
|
||||
|
||||
# Определяем тип по первому элементу
|
||||
link_type = ReferralType.DEEPLINK
|
||||
|
||||
if parts[0].lower() in ['ref', 'referral']:
|
||||
link_type = ReferralType.REFERRAL
|
||||
if len(parts) > 1:
|
||||
params['ref_code'] = parts[1]
|
||||
parts = parts[2:] # Пропускаем первые 2 элемента
|
||||
elif parts[0].lower() == 'promo':
|
||||
link_type = ReferralType.PROMO
|
||||
if len(parts) > 1:
|
||||
params['promo_code'] = parts[1]
|
||||
parts = parts[2:]
|
||||
elif parts[0].lower() == 'invite':
|
||||
link_type = ReferralType.INVITE
|
||||
if len(parts) > 1:
|
||||
params['invite_code'] = parts[1]
|
||||
parts = parts[2:]
|
||||
|
||||
# Парсим остальные параметры как пары ключ-значение
|
||||
i = 0
|
||||
while i < len(parts) - 1:
|
||||
key = parts[i]
|
||||
value = parts[i + 1]
|
||||
|
||||
# Пытаемся преобразовать в число
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
pass # Оставляем строкой
|
||||
|
||||
params[key] = value
|
||||
i += 2
|
||||
|
||||
return link_type, params
|
||||
|
||||
def _validate_deep_link(self, args: str) -> bool:
|
||||
"""
|
||||
Валидирует deep link.
|
||||
|
||||
Args:
|
||||
args: Строка для валидации
|
||||
|
||||
Returns:
|
||||
bool: True если валиден
|
||||
"""
|
||||
# Проверка длины
|
||||
if len(args) > self.max_length:
|
||||
logger.warning(
|
||||
f"Deep link слишком длинный: {len(args)} > {self.max_length}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка на запрещенные символы (только буквы, цифры, _ и -)
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', args):
|
||||
logger.warning(
|
||||
f"Deep link содержит недопустимые символы: {args}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
return False
|
||||
|
||||
# Кастомная валидация
|
||||
if self.validator:
|
||||
return self.validator(args)
|
||||
|
||||
return True
|
||||
|
||||
def _parse_deep_link(self, args: str, user: User) -> DeepLinkData:
|
||||
"""
|
||||
Парсит deep link и создает объект DeepLinkData.
|
||||
|
||||
Args:
|
||||
args: Аргументы команды /start
|
||||
user: Пользователь, перешедший по ссылке
|
||||
|
||||
Returns:
|
||||
DeepLinkData: Распарсенные данные
|
||||
"""
|
||||
# Валидация
|
||||
is_valid = self._validate_deep_link(args)
|
||||
|
||||
# Парсинг
|
||||
if self.parse_complex and '_' in args:
|
||||
link_type, params = self._parse_complex(args)
|
||||
else:
|
||||
link_type, params = self._parse_simple(args)
|
||||
|
||||
# Создаем объект
|
||||
deep_link = DeepLinkData(
|
||||
raw=args,
|
||||
type=link_type,
|
||||
params=params,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_valid=is_valid
|
||||
)
|
||||
|
||||
return deep_link
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Перехватывает команды /start с аргументами.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат хендлера
|
||||
"""
|
||||
# Обрабатываем только сообщения
|
||||
if not isinstance(event, Message):
|
||||
return await handler(event, data)
|
||||
|
||||
# Извлекаем команду
|
||||
command: Optional[CommandObject] = data.get('command')
|
||||
|
||||
# Проверяем, что это /start с аргументами
|
||||
if not command or command.command.lower() != 'start' or not command.args:
|
||||
return await handler(event, data)
|
||||
|
||||
user = event.from_user
|
||||
args = command.args
|
||||
|
||||
# Парсим deep link
|
||||
deep_link = self._parse_deep_link(args, user)
|
||||
|
||||
# Логирование
|
||||
if deep_link.is_valid:
|
||||
logger.info(
|
||||
f"Deep link: type={deep_link.type}, params={deep_link.params}",
|
||||
log_type='REFERRAL',
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Невалидный deep link: {args}",
|
||||
log_type='REFERRAL',
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
|
||||
# Собираем статистику
|
||||
if self.collect_stats and deep_link.is_valid:
|
||||
referral_stats.record(deep_link)
|
||||
|
||||
# Вызываем callback для сохранения в БД
|
||||
if self.on_referral and deep_link.is_valid:
|
||||
try:
|
||||
await self.on_referral(deep_link)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка в on_referral callback: {e}",
|
||||
log_type='REFERRAL'
|
||||
)
|
||||
|
||||
# Добавляем deep_link в data для хендлера
|
||||
data['deep_link'] = deep_link
|
||||
data['ref_code'] = deep_link.get('ref_code') # Для обратной совместимости
|
||||
|
||||
# Выполняем хендлер
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def create_deep_link(bot_username: str, **params) -> str:
|
||||
"""
|
||||
Создает deep link для бота.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота (без @)
|
||||
**params: Параметры для ссылки
|
||||
|
||||
Returns:
|
||||
str: Готовая ссылка
|
||||
|
||||
Example:
|
||||
>>> create_deep_link('mybot', ref_code='123', bonus='50')
|
||||
'https://t.me/mybot?start=ref_123_bonus_50'
|
||||
"""
|
||||
# Формируем строку параметров
|
||||
parts = []
|
||||
|
||||
for key, value in params.items():
|
||||
parts.append(str(key))
|
||||
parts.append(str(value))
|
||||
|
||||
param_string = '_'.join(parts)
|
||||
|
||||
return f"https://t.me/{bot_username}?start={param_string}"
|
||||
|
||||
|
||||
def create_referral_link(bot_username: str, ref_code: str) -> str:
|
||||
"""
|
||||
Создает простую реферальную ссылку.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота
|
||||
ref_code: Реферальный код
|
||||
|
||||
Returns:
|
||||
str: Реферальная ссылка
|
||||
|
||||
Example:
|
||||
>>> create_referral_link('mybot', '123')
|
||||
'https://t.me/mybot?start=ref_123'
|
||||
"""
|
||||
return f"https://t.me/{bot_username}?start=ref_{ref_code}"
|
||||
|
||||
|
||||
def create_promo_link(bot_username: str, promo_code: str) -> str:
|
||||
"""
|
||||
Создает ссылку с промокодом.
|
||||
|
||||
Args:
|
||||
bot_username: Username бота
|
||||
promo_code: Промокод
|
||||
|
||||
Returns:
|
||||
str: Ссылка с промокодом
|
||||
|
||||
Example:
|
||||
>>> create_promo_link('mybot', 'SUMMER2024')
|
||||
'https://t.me/mybot?start=promo_SUMMER2024'
|
||||
"""
|
||||
return f"https://t.me/{bot_username}?start=promo_{promo_code}"
|
||||
575
bot/middlewares/spam_mdw.py
Normal file
575
bot/middlewares/spam_mdw.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
Умный middleware для защиты от спама с адаптивными лимитами
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from collections import Counter
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from middleware.loggers import logger
|
||||
from configs import settings
|
||||
|
||||
__all__ = ('AntiSpamMiddleware', 'spam_stats')
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContext:
|
||||
"""Контекст сообщения для умной детекции"""
|
||||
text: Optional[str] = None
|
||||
is_forward: bool = False
|
||||
is_reply: bool = False
|
||||
is_command: bool = False
|
||||
media_type: Optional[str] = None
|
||||
callback_data: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserSpamStats:
|
||||
"""
|
||||
Расширенная статистика спама для пользователя.
|
||||
"""
|
||||
user_id: int
|
||||
request_times: list[float] = field(default_factory=list)
|
||||
message_contexts: list[MessageContext] = field(default_factory=list)
|
||||
warnings: int = 0
|
||||
blocked_until: Optional[float] = None
|
||||
total_requests: int = 0
|
||||
total_blocks: int = 0
|
||||
first_seen: Optional[float] = None
|
||||
last_seen: Optional[float] = None
|
||||
reputation: float = 1.0 # Репутация пользователя (0.5 - 2.0)
|
||||
|
||||
def is_blocked(self, current_time: float) -> bool:
|
||||
"""Проверяет, заблокирован ли пользователь"""
|
||||
if self.blocked_until is None:
|
||||
return False
|
||||
|
||||
if current_time < self.blocked_until:
|
||||
return True
|
||||
|
||||
# Разблокировка
|
||||
self.blocked_until = None
|
||||
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью
|
||||
return False
|
||||
|
||||
def get_remaining_block_time(self, current_time: float) -> float:
|
||||
"""Возвращает оставшееся время блокировки"""
|
||||
if self.blocked_until is None or current_time >= self.blocked_until:
|
||||
return 0.0
|
||||
return self.blocked_until - current_time
|
||||
|
||||
def clean_old_requests(self, current_time: float, time_window: float) -> None:
|
||||
"""Удаляет старые запросы за пределами временного окна"""
|
||||
cutoff_time = current_time - time_window
|
||||
|
||||
# Удаляем старые запросы
|
||||
new_times = []
|
||||
new_contexts = []
|
||||
|
||||
for req_time, context in zip(self.request_times, self.message_contexts):
|
||||
if req_time > cutoff_time:
|
||||
new_times.append(req_time)
|
||||
new_contexts.append(context)
|
||||
|
||||
self.request_times = new_times
|
||||
self.message_contexts = new_contexts
|
||||
|
||||
def add_request(self, current_time: float, context: MessageContext) -> None:
|
||||
"""Добавляет новый запрос с контекстом"""
|
||||
self.request_times.append(current_time)
|
||||
self.message_contexts.append(context)
|
||||
self.total_requests += 1
|
||||
self.last_seen = current_time
|
||||
|
||||
if self.first_seen is None:
|
||||
self.first_seen = current_time
|
||||
|
||||
def add_warning(self) -> None:
|
||||
"""Добавляет предупреждение и снижает репутацию"""
|
||||
self.warnings += 1
|
||||
self.reputation = max(0.5, self.reputation - 0.1)
|
||||
|
||||
def improve_reputation(self) -> None:
|
||||
"""Улучшает репутацию за хорошее поведение"""
|
||||
self.reputation = min(2.0, self.reputation + 0.05)
|
||||
|
||||
def block(self, current_time: float, duration: float) -> None:
|
||||
"""Блокирует пользователя"""
|
||||
self.blocked_until = current_time + duration
|
||||
self.total_blocks += 1
|
||||
self.reputation = max(0.5, self.reputation - 0.3)
|
||||
|
||||
def detect_spam_patterns(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Умная детекция спама на основе паттернов.
|
||||
|
||||
Returns:
|
||||
Dict с результатами анализа
|
||||
"""
|
||||
if len(self.message_contexts) < 3:
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
recent_contexts = self.message_contexts[-10:] # Последние 10 сообщений
|
||||
|
||||
# 1. Проверка идентичных текстовых сообщений
|
||||
texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command]
|
||||
if texts:
|
||||
text_counts = Counter(texts)
|
||||
most_common_text, count = text_counts.most_common(1)[0]
|
||||
|
||||
if count >= 5: # 5 одинаковых сообщений подряд
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'identical_messages',
|
||||
'severity': 1.0,
|
||||
'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'"
|
||||
}
|
||||
|
||||
# 2. Проверка спама callback кнопок
|
||||
callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data]
|
||||
if callbacks:
|
||||
callback_counts = Counter(callbacks)
|
||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||
|
||||
if count >= 8: # 8 нажатий одной кнопки
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'callback_spam',
|
||||
'severity': 0.8,
|
||||
'details': f"Спам кнопки: {most_common_callback}"
|
||||
}
|
||||
|
||||
# 3. Проверка флуда медиа
|
||||
media_types = [ctx.media_type for ctx in recent_contexts if ctx.media_type]
|
||||
if len(media_types) >= 7: # 7+ медиафайлов подряд
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'media_flood',
|
||||
'severity': 0.6,
|
||||
'details': f"Флуд медиа: {len(media_types)} файлов"
|
||||
}
|
||||
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
|
||||
class SpamStatistics:
|
||||
"""Глобальная статистика по спаму"""
|
||||
|
||||
def __init__(self):
|
||||
self.users: Dict[int, UserSpamStats] = {}
|
||||
self.total_blocked_requests: int = 0
|
||||
self.total_warnings_issued: int = 0
|
||||
|
||||
def get_user(self, user_id: int) -> UserSpamStats:
|
||||
"""Получает или создает статистику пользователя"""
|
||||
if user_id not in self.users:
|
||||
self.users[user_id] = UserSpamStats(user_id=user_id)
|
||||
return self.users[user_id]
|
||||
|
||||
def get_top_spammers(self, limit: int = 10) -> list[tuple[int, int]]:
|
||||
"""Возвращает топ спамеров"""
|
||||
sorted_users = sorted(
|
||||
self.users.items(),
|
||||
key=lambda x: x[1].total_blocks,
|
||||
reverse=True
|
||||
)
|
||||
return [(uid, stats.total_blocks) for uid, stats in sorted_users[:limit]]
|
||||
|
||||
def get_stats_summary(self) -> Dict[str, Any]:
|
||||
"""Возвращает общую статистику"""
|
||||
return {
|
||||
'total_users': len(self.users),
|
||||
'total_blocked_requests': self.total_blocked_requests,
|
||||
'total_warnings': self.total_warnings_issued,
|
||||
'active_blocks': sum(
|
||||
1 for stats in self.users.values()
|
||||
if stats.blocked_until and stats.blocked_until > time()
|
||||
)
|
||||
}
|
||||
|
||||
def cleanup(self, max_age: float = 86400.0) -> int:
|
||||
"""Удаляет старую статистику (24 часа по умолчанию)"""
|
||||
current_time = time()
|
||||
cutoff_time = current_time - max_age
|
||||
|
||||
users_to_delete = [
|
||||
uid for uid, stats in self.users.items()
|
||||
if stats.last_seen and stats.last_seen < cutoff_time
|
||||
and not stats.is_blocked(current_time)
|
||||
]
|
||||
|
||||
for uid in users_to_delete:
|
||||
del self.users[uid]
|
||||
|
||||
return len(users_to_delete)
|
||||
|
||||
|
||||
# Глобальная статистика
|
||||
spam_stats = SpamStatistics()
|
||||
|
||||
|
||||
class AntiSpamMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Умный антиспам с адаптивными лимитами.
|
||||
|
||||
Особенности:
|
||||
- Различает типы активности (текст, форварды, команды, callback)
|
||||
- Адаптивные лимиты в зависимости от типа сообщения
|
||||
- Система репутации пользователей
|
||||
- Умная детекция спам-паттернов
|
||||
- Мягкое отношение к пересылкам и ответам
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Базовые лимиты
|
||||
rate_limit_text: int = 8, # Текстовых сообщений за окно
|
||||
rate_limit_forward: int = 20, # Пересылок за окно
|
||||
rate_limit_callback: int = 10, # Нажатий кнопок за окно
|
||||
rate_limit_media: int = 10, # Медиа за окно
|
||||
|
||||
time_window: float = 10.0, # Временное окно (секунды)
|
||||
|
||||
# Предупреждения и блокировки
|
||||
warning_limit: int = 3,
|
||||
block_duration: float = 120.0, # 2 минуты базовая блокировка
|
||||
max_block_duration: float = 3600.0, # 1 час максимум
|
||||
|
||||
# Опции
|
||||
whitelist_admins: bool = True,
|
||||
progressive_blocking: bool = True,
|
||||
enable_smart_detection: bool = True,
|
||||
enable_reputation: bool = True,
|
||||
log_all: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self.rate_limit_text = rate_limit_text
|
||||
self.rate_limit_forward = rate_limit_forward
|
||||
self.rate_limit_callback = rate_limit_callback
|
||||
self.rate_limit_media = rate_limit_media
|
||||
self.time_window = time_window
|
||||
self.warning_limit = warning_limit
|
||||
self.block_duration = block_duration
|
||||
self.max_block_duration = max_block_duration
|
||||
self.whitelist_admins = whitelist_admins
|
||||
self.progressive_blocking = progressive_blocking
|
||||
self.enable_smart_detection = enable_smart_detection
|
||||
self.enable_reputation = enable_reputation
|
||||
self.log_all = log_all
|
||||
|
||||
def _extract_context(self, event: TelegramObject) -> MessageContext:
|
||||
"""Извлекает контекст из события"""
|
||||
context = MessageContext()
|
||||
|
||||
if isinstance(event, Message):
|
||||
context.text = event.text or event.caption
|
||||
context.is_forward = event.forward_date is not None
|
||||
context.is_reply = event.reply_to_message is not None
|
||||
context.is_command = bool(context.text and context.text.startswith('/'))
|
||||
|
||||
# Определяем тип медиа
|
||||
if event.photo:
|
||||
context.media_type = 'photo'
|
||||
elif event.video:
|
||||
context.media_type = 'video'
|
||||
elif event.document:
|
||||
context.media_type = 'document'
|
||||
elif event.audio:
|
||||
context.media_type = 'audio'
|
||||
elif event.voice:
|
||||
context.media_type = 'voice'
|
||||
elif event.sticker:
|
||||
context.media_type = 'sticker'
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
context.callback_data = event.data
|
||||
|
||||
return context
|
||||
|
||||
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
|
||||
"""Вычисляет эффективный лимит с учётом типа и репутации"""
|
||||
# Базовый лимит по типу
|
||||
if context.is_command:
|
||||
return 999 # Команды не ограничиваем
|
||||
elif context.callback_data:
|
||||
base_limit = self.rate_limit_callback
|
||||
elif context.is_forward:
|
||||
base_limit = self.rate_limit_forward
|
||||
elif context.media_type:
|
||||
base_limit = self.rate_limit_media
|
||||
else:
|
||||
base_limit = self.rate_limit_text
|
||||
|
||||
# Применяем репутацию
|
||||
if self.enable_reputation:
|
||||
base_limit = int(base_limit * user_stats.reputation)
|
||||
|
||||
return max(3, base_limit) # Минимум 3 сообщения
|
||||
|
||||
def _calculate_block_duration(self, warnings: int) -> float:
|
||||
"""Вычисляет длительность блокировки"""
|
||||
if not self.progressive_blocking:
|
||||
return self.block_duration
|
||||
|
||||
multiplier = 2 ** (warnings // self.warning_limit)
|
||||
duration = self.block_duration * multiplier
|
||||
|
||||
return min(duration, self.max_block_duration)
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Форматирует длительность"""
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)} сек"
|
||||
elif seconds < 3600:
|
||||
return f"{int(seconds / 60)} мин"
|
||||
else:
|
||||
return f"{int(seconds / 3600)} час"
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Optional[Any]:
|
||||
"""Основная логика проверки"""
|
||||
|
||||
# Пропускаем не-сообщения и не-callback
|
||||
if not isinstance(event, (Message, CallbackQuery)):
|
||||
return await handler(event, data)
|
||||
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||
|
||||
# Whitelist для администраторов
|
||||
if self.whitelist_admins and user_id in (settings.OWNER_ID + settings.ADMIN_ID):
|
||||
if self.log_all:
|
||||
logger.debug(f"Администратор {user_str} пропущен", log_type='ANTI_SPAM')
|
||||
return await handler(event, data)
|
||||
|
||||
current_time = time()
|
||||
user_stats = spam_stats.get_user(user_id)
|
||||
|
||||
# Проверка блокировки
|
||||
if user_stats.is_blocked(current_time):
|
||||
remaining = user_stats.get_remaining_block_time(current_time)
|
||||
spam_stats.total_blocked_requests += 1
|
||||
|
||||
logger.warning(
|
||||
f"Запрос от заблокированного пользователя (осталось {self._format_duration(remaining)})",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Оставшееся время: <b>{self._format_duration(remaining)}</b>\n"
|
||||
f"⚠️ Предупреждений: <b>{user_stats.warnings}</b>"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Заблокирован на {self._format_duration(remaining)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Извлекаем контекст сообщения
|
||||
context = self._extract_context(event)
|
||||
|
||||
# Очищаем старые запросы
|
||||
user_stats.clean_old_requests(current_time, self.time_window)
|
||||
|
||||
# Умная детекция спам-паттернов
|
||||
if self.enable_smart_detection:
|
||||
spam_analysis = user_stats.detect_spam_patterns()
|
||||
|
||||
if spam_analysis['is_spam']:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
|
||||
logger.warning(
|
||||
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Немедленная блокировка при явном спаме
|
||||
if spam_analysis['severity'] >= 0.9:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"⚠️ Причина: {spam_analysis['details']}"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка: {spam_analysis['reason']}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Получаем эффективный лимит
|
||||
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
||||
|
||||
# Подсчитываем релевантные запросы
|
||||
relevant_requests = 0
|
||||
for req_context in user_stats.message_contexts:
|
||||
if context.is_forward and req_context.is_forward:
|
||||
relevant_requests += 1
|
||||
elif context.callback_data and req_context.callback_data:
|
||||
relevant_requests += 1
|
||||
elif context.media_type and req_context.media_type:
|
||||
relevant_requests += 1
|
||||
elif not (req_context.is_forward or req_context.callback_data or req_context.media_type or req_context.is_command):
|
||||
relevant_requests += 1
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Rate limit: {relevant_requests}/{effective_limit} (тип: {context.media_type or 'text'}, репутация: {user_stats.reputation:.2f})",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Проверка лимита
|
||||
if relevant_requests >= effective_limit:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
|
||||
logger.warning(
|
||||
f"Превышен rate limit ({relevant_requests}/{effective_limit}). "
|
||||
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Блокировка при достижении лимита предупреждений
|
||||
if user_stats.warnings >= self.warning_limit:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован на {self._format_duration(block_duration)}. "
|
||||
f"Всего блокировок: {user_stats.total_blocks}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"⚠️ Причина: Превышение лимита запросов\n"
|
||||
f"📊 Это блокировка #{user_stats.total_blocks}"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка на {self._format_duration(block_duration)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Предупреждение
|
||||
warning_message = (
|
||||
f"⚠️ <b>Предупреждение #{user_stats.warnings}</b>\n\n"
|
||||
f"Вы отправляете запросы слишком часто!\n"
|
||||
f"Лимит: {effective_limit} запросов за {self._format_duration(self.time_window)}\n\n"
|
||||
f"При {self.warning_limit} предупреждениях последует блокировка."
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(warning_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_stats.add_request(current_time, context)
|
||||
|
||||
# Улучшаем репутацию за нормальное поведение
|
||||
if self.enable_reputation and user_stats.total_requests % 10 == 0:
|
||||
user_stats.improve_reputation()
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Запрос разрешен. Всего: {user_stats.total_requests}, репутация: {user_stats.reputation:.2f}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# ================= УПРАВЛЕНИЕ =================
|
||||
|
||||
async def reset_spam_warnings(user_id: int) -> bool:
|
||||
"""Сбрасывает предупреждения пользователя"""
|
||||
if user_id in spam_stats.users:
|
||||
spam_stats.users[user_id].warnings = 0
|
||||
spam_stats.users[user_id].blocked_until = None
|
||||
logger.info(f"Предупреждения сброшены для id{user_id}", log_type='ANTI_SPAM')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def unblock_user(user_id: int) -> bool:
|
||||
"""Разблокирует пользователя"""
|
||||
if user_id in spam_stats.users:
|
||||
stats = spam_stats.users[user_id]
|
||||
if stats.blocked_until:
|
||||
stats.blocked_until = None
|
||||
stats.warnings = 0
|
||||
logger.info(f"Пользователь id{user_id} разблокирован вручную", log_type='ANTI_SPAM')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_spam_info(user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Получает информацию о спам-статистике пользователя"""
|
||||
if user_id not in spam_stats.users:
|
||||
return None
|
||||
|
||||
stats = spam_stats.users[user_id]
|
||||
current_time = time()
|
||||
|
||||
return {
|
||||
'user_id': user_id,
|
||||
'warnings': stats.warnings,
|
||||
'reputation': stats.reputation,
|
||||
'is_blocked': stats.is_blocked(current_time),
|
||||
'blocked_until': datetime.fromtimestamp(stats.blocked_until) if stats.blocked_until else None,
|
||||
'remaining_block_time': stats.get_remaining_block_time(current_time),
|
||||
'total_requests': stats.total_requests,
|
||||
'total_blocks': stats.total_blocks,
|
||||
'first_seen': datetime.fromtimestamp(stats.first_seen) if stats.first_seen else None,
|
||||
'last_seen': datetime.fromtimestamp(stats.last_seen) if stats.last_seen else None
|
||||
}
|
||||
553
bot/middlewares/sub_mdw.py
Normal file
553
bot/middlewares/sub_mdw.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
Middleware для проверки подписки пользователей на каналы
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, InlineKeyboardButton, Chat
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from middleware.loggers import logger
|
||||
from configs import settings
|
||||
|
||||
__all__ = ('SubscriptionMiddleware', 'ChannelConfig')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelConfig:
|
||||
"""
|
||||
Конфигурация канала для проверки подписки.
|
||||
|
||||
Attributes:
|
||||
id: ID или username канала
|
||||
name: Название канала (для отображения)
|
||||
invite_link: Пригласительная ссылка
|
||||
required: Обязательная ли подписка
|
||||
"""
|
||||
id: Union[str, int]
|
||||
name: Optional[str] = None
|
||||
invite_link: Optional[str] = None
|
||||
required: bool = True
|
||||
|
||||
|
||||
class SubscriptionCache:
|
||||
"""
|
||||
Кэш для проверок подписки.
|
||||
|
||||
Уменьшает количество запросов к Telegram API.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: float = 300.0):
|
||||
"""
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут)
|
||||
"""
|
||||
self.ttl = ttl
|
||||
# Структура: {(user_id, channel_id): (is_subscribed, timestamp)}
|
||||
self._cache: Dict[tuple[int, Union[str, int]], tuple[bool, float]] = {}
|
||||
|
||||
def get(self, user_id: int, channel_id: Union[str, int]) -> Optional[bool]:
|
||||
"""
|
||||
Получает значение из кэша.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
|
||||
Returns:
|
||||
bool или None: True/False если в кэше и актуально, иначе None
|
||||
"""
|
||||
key = (user_id, channel_id)
|
||||
|
||||
if key in self._cache:
|
||||
is_subscribed, timestamp = self._cache[key]
|
||||
|
||||
# Проверяем актуальность
|
||||
if time() - timestamp < self.ttl:
|
||||
return is_subscribed
|
||||
else:
|
||||
# Удаляем устаревшую запись
|
||||
del self._cache[key]
|
||||
|
||||
return None
|
||||
|
||||
def set(self, user_id: int, channel_id: Union[str, int], is_subscribed: bool) -> None:
|
||||
"""
|
||||
Сохраняет значение в кэш.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
is_subscribed: Статус подписки
|
||||
"""
|
||||
key = (user_id, channel_id)
|
||||
self._cache[key] = (is_subscribed, time())
|
||||
|
||||
def invalidate(self, user_id: Optional[int] = None, channel_id: Optional[Union[str, int]] = None) -> None:
|
||||
"""
|
||||
Инвалидирует кэш.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя (если None, инвалидирует все)
|
||||
channel_id: ID канала (если None, инвалидирует все для пользователя)
|
||||
"""
|
||||
if user_id is None and channel_id is None:
|
||||
# Полная очистка
|
||||
self._cache.clear()
|
||||
elif user_id is not None and channel_id is None:
|
||||
# Удаляем все записи пользователя
|
||||
keys_to_delete = [key for key in self._cache if key[0] == user_id]
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
elif user_id is not None and channel_id is not None:
|
||||
# Удаляем конкретную запись
|
||||
key = (user_id, channel_id)
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Удаляет устаревшие записи.
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных записей
|
||||
"""
|
||||
current_time = time()
|
||||
keys_to_delete = [
|
||||
key for key, (_, timestamp) in self._cache.items()
|
||||
if current_time - timestamp >= self.ttl
|
||||
]
|
||||
|
||||
for key in keys_to_delete:
|
||||
del self._cache[key]
|
||||
|
||||
return len(keys_to_delete)
|
||||
|
||||
|
||||
class SubscriptionMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для проверки подписки пользователя на каналы.
|
||||
|
||||
Возможности:
|
||||
- Проверка подписки на один или несколько каналов
|
||||
- Кэширование результатов проверки
|
||||
- Whitelist для администраторов
|
||||
- Автоматическое получение ссылок на каналы
|
||||
- Гибкая настройка обязательных/необязательных каналов
|
||||
- Красивое сообщение с кнопками подписки
|
||||
|
||||
Attributes:
|
||||
bot: Экземпляр бота
|
||||
channels: Список конфигураций каналов
|
||||
cache_ttl: Время жизни кэша в секундах
|
||||
whitelist_admins: Пропускать ли администраторов бота
|
||||
show_buttons: Показывать ли кнопки для подписки
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.subscription import SubscriptionMiddleware, ChannelConfig
|
||||
|
||||
channels = [
|
||||
ChannelConfig(
|
||||
id="@my_channel",
|
||||
name="Основной канал",
|
||||
invite_link="https://t.me/my_channel"
|
||||
),
|
||||
ChannelConfig(
|
||||
id=-1001234567890,
|
||||
name="Закрытый канал",
|
||||
required=True
|
||||
)
|
||||
]
|
||||
|
||||
dp.message.middleware(SubscriptionMiddleware(bot, channels))
|
||||
dp.callback_query.middleware(SubscriptionMiddleware(bot, channels))
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
channels: list[Union[ChannelConfig, str, int]],
|
||||
cache_ttl: float = 300.0,
|
||||
whitelist_admins: bool = True,
|
||||
show_buttons: bool = True,
|
||||
auto_fetch_links: bool = True
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
channels: Список каналов (ChannelConfig, ID или username)
|
||||
cache_ttl: Время жизни кэша в секундах
|
||||
whitelist_admins: Пропускать администраторов бота
|
||||
show_buttons: Показывать кнопки подписки
|
||||
auto_fetch_links: Автоматически получать ссылки на каналы
|
||||
"""
|
||||
super().__init__()
|
||||
self.bot = bot
|
||||
self.cache = SubscriptionCache(ttl=cache_ttl)
|
||||
self.whitelist_admins = whitelist_admins
|
||||
self.show_buttons = show_buttons
|
||||
self.auto_fetch_links = auto_fetch_links
|
||||
|
||||
# Преобразуем channels в ChannelConfig
|
||||
self.channels: list[ChannelConfig] = []
|
||||
for channel in channels:
|
||||
if isinstance(channel, ChannelConfig):
|
||||
self.channels.append(channel)
|
||||
else:
|
||||
# Простой ID/username -> ChannelConfig
|
||||
self.channels.append(ChannelConfig(id=channel))
|
||||
|
||||
# Кэш информации о каналах
|
||||
self._channel_info_cache: Dict[Union[str, int], Optional[Chat]] = {}
|
||||
|
||||
async def _get_channel_info(self, channel_id: Union[str, int]) -> Optional[Chat]:
|
||||
"""
|
||||
Получает информацию о канале.
|
||||
|
||||
Args:
|
||||
channel_id: ID или username канала
|
||||
|
||||
Returns:
|
||||
Chat или None: Информация о канале
|
||||
"""
|
||||
if channel_id in self._channel_info_cache:
|
||||
return self._channel_info_cache[channel_id]
|
||||
|
||||
try:
|
||||
chat = await self.bot.get_chat(channel_id)
|
||||
self._channel_info_cache[channel_id] = chat
|
||||
return chat
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.error(
|
||||
f"Не удалось получить информацию о канале {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
self._channel_info_cache[channel_id] = None
|
||||
return None
|
||||
|
||||
async def _check_subscription(
|
||||
self,
|
||||
user_id: int,
|
||||
channel_config: ChannelConfig
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет подписку пользователя на канал.
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_config: Конфигурация канала
|
||||
|
||||
Returns:
|
||||
bool: True если подписан
|
||||
"""
|
||||
channel_id = channel_config.id
|
||||
|
||||
# Проверяем кэш
|
||||
cached = self.cache.get(user_id, channel_id)
|
||||
if cached is not None:
|
||||
logger.debug(
|
||||
f"Использован кэш для проверки подписки на {channel_id}: {cached}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return cached
|
||||
|
||||
# Выполняем проверку
|
||||
try:
|
||||
member = await self.bot.get_chat_member(
|
||||
chat_id=channel_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
is_subscribed = member.status in (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
)
|
||||
|
||||
# Сохраняем в кэш
|
||||
self.cache.set(user_id, channel_id, is_subscribed)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка подписки user={user_id} на канал={channel_id}: "
|
||||
f"{member.status.value} ({'✅' if is_subscribed else '❌'})",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
|
||||
return is_subscribed
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(
|
||||
f"Канал {channel_id} недоступен или неверный: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
# В случае ошибки считаем что не подписан
|
||||
self.cache.set(user_id, channel_id, False)
|
||||
return False
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(
|
||||
f"Бот не имеет доступа к каналу {channel_id}: {e}",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
self.cache.set(user_id, channel_id, False)
|
||||
return False
|
||||
|
||||
async def _build_subscription_message(
|
||||
self,
|
||||
not_subscribed: list[ChannelConfig]
|
||||
) -> tuple[str, InlineKeyboardBuilder]:
|
||||
"""
|
||||
Создает сообщение и клавиатуру для подписки.
|
||||
|
||||
Args:
|
||||
not_subscribed: Список каналов без подписки
|
||||
|
||||
Returns:
|
||||
tuple: (текст_сообщения, клавиатура)
|
||||
"""
|
||||
# Текст сообщения
|
||||
text = "📢 <b>Для использования бота необходимо подписаться на каналы:</b>\n\n"
|
||||
|
||||
# Клавиатура
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
|
||||
for i, channel_config in enumerate(not_subscribed, 1):
|
||||
# Получаем информацию о канале
|
||||
channel_info = await self._get_channel_info(channel_config.id)
|
||||
|
||||
# Определяем название канала
|
||||
if channel_config.name:
|
||||
channel_name = channel_config.name
|
||||
elif channel_info:
|
||||
channel_name = channel_info.title
|
||||
else:
|
||||
channel_name = f"Канал {i}"
|
||||
|
||||
# Добавляем в текст
|
||||
text += f"{i}. {channel_name}\n"
|
||||
|
||||
# Определяем ссылку
|
||||
invite_link = channel_config.invite_link
|
||||
|
||||
if not invite_link and self.auto_fetch_links and channel_info:
|
||||
# Пытаемся получить ссылку
|
||||
if channel_info.username:
|
||||
invite_link = f"https://t.me/{channel_info.username}"
|
||||
elif channel_info.invite_link:
|
||||
invite_link = channel_info.invite_link
|
||||
|
||||
# Добавляем кнопку если есть ссылка
|
||||
if invite_link and self.show_buttons:
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📌 {channel_name}",
|
||||
url=invite_link
|
||||
)
|
||||
)
|
||||
|
||||
text += "\n✅ После подписки нажмите кнопку ниже для проверки."
|
||||
|
||||
# Кнопка проверки подписки
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался",
|
||||
callback_data="check_subscription"
|
||||
)
|
||||
)
|
||||
|
||||
return text, keyboard
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Проверяет подписку перед выполнением хендлера.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат хендлера или None если не подписан
|
||||
"""
|
||||
# Пропускаем не-сообщения и не-callback
|
||||
if not isinstance(event, (Message, CallbackQuery)):
|
||||
return await handler(event, data)
|
||||
|
||||
# Извлекаем user_id
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
|
||||
if user_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||
|
||||
# Whitelist для администраторов
|
||||
if self.whitelist_admins and user_id in settings.super_admin_ids:
|
||||
logger.debug(
|
||||
f"Администратор {user_str} пропущен без проверки подписки",
|
||||
log_type='SUBSCRIPTION'
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
# Проверяем подписку на все каналы
|
||||
not_subscribed: list[ChannelConfig] = []
|
||||
|
||||
for channel_config in self.channels:
|
||||
# Пропускаем необязательные каналы
|
||||
if not channel_config.required:
|
||||
continue
|
||||
|
||||
is_subscribed = await self._check_subscription(user_id, channel_config)
|
||||
|
||||
if not is_subscribed:
|
||||
not_subscribed.append(channel_config)
|
||||
|
||||
# Если есть каналы без подписки
|
||||
if not_subscribed:
|
||||
logger.info(
|
||||
f"Пользователь не подписан на {len(not_subscribed)} каналов",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Создаем сообщение
|
||||
text, keyboard = await self._build_subscription_message(not_subscribed)
|
||||
|
||||
# Отправляем сообщение
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
# Для callback отправляем в чат или редактируем
|
||||
if event.message:
|
||||
try:
|
||||
await event.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except:
|
||||
await event.message.answer(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await event.answer(
|
||||
"⚠️ Требуется подписка на каналы",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Все подписки в порядке
|
||||
logger.debug(
|
||||
f"Проверка подписки пройдена",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
def invalidate_cache(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
channel_id: Optional[Union[str, int]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Публичный метод для инвалидации кэша.
|
||||
|
||||
Используется при обработке callback "check_subscription".
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
channel_id: ID канала
|
||||
"""
|
||||
self.cache.invalidate(user_id, channel_id)
|
||||
|
||||
|
||||
# ================= HANDLER ДЛЯ ПРОВЕРКИ ПОДПИСКИ =================
|
||||
|
||||
async def handle_check_subscription(
|
||||
callback: CallbackQuery,
|
||||
subscription_middleware: SubscriptionMiddleware
|
||||
):
|
||||
"""
|
||||
Обработчик callback для повторной проверки подписки.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from filters.callback import CallbackStartsWith
|
||||
from middleware.subscription import handle_check_subscription, subscription_middleware
|
||||
|
||||
@router.callback_query(CallbackStartsWith("check_subscription"))
|
||||
async def check_sub(callback: CallbackQuery):
|
||||
await handle_check_subscription(callback, subscription_middleware)
|
||||
```
|
||||
"""
|
||||
user_id = callback.from_user.id
|
||||
|
||||
# Инвалидируем кэш для пользователя
|
||||
subscription_middleware.invalidate_cache(user_id=user_id)
|
||||
|
||||
await callback.answer("🔄 Проверяю подписку...", show_alert=False)
|
||||
|
||||
# Перепроверяем подписку
|
||||
not_subscribed = []
|
||||
|
||||
for channel_config in subscription_middleware.channels:
|
||||
if not channel_config.required:
|
||||
continue
|
||||
|
||||
is_subscribed = await subscription_middleware._check_subscription(
|
||||
user_id,
|
||||
channel_config
|
||||
)
|
||||
|
||||
if not is_subscribed:
|
||||
not_subscribed.append(channel_config)
|
||||
|
||||
if not_subscribed:
|
||||
# Все еще не подписан
|
||||
text, keyboard = await subscription_middleware._build_subscription_message(not_subscribed)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
f"❌ Вы еще не подписаны на {len(not_subscribed)} каналов",
|
||||
show_alert=True
|
||||
)
|
||||
else:
|
||||
# Подписка подтверждена
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
"✅ <b>Подписка подтверждена!</b>\n\n"
|
||||
"Теперь вы можете пользоваться ботом. Используйте /start",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Подписка успешно подтверждена",
|
||||
log_type='SUBSCRIPTION',
|
||||
user=f"@{callback.from_user.username}" if callback.from_user.username else f"id{user_id}"
|
||||
)
|
||||
311
bot/middlewares/time_mdw.py
Normal file
311
bot/middlewares/time_mdw.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Middleware для измерения времени выполнения хендлеров
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery, Update, User
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = ('TimingMiddleware', 'TimingStats')
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandlerMetrics:
|
||||
"""Метрики одного хендлера"""
|
||||
total_calls: int = 0
|
||||
total_time: float = 0.0
|
||||
min_time: float = float('inf')
|
||||
max_time: float = 0.0
|
||||
last_call: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def avg_time(self) -> float:
|
||||
"""Среднее время выполнения"""
|
||||
return self.total_time / self.total_calls if self.total_calls > 0 else 0.0
|
||||
|
||||
def update(self, execution_time: float) -> None:
|
||||
"""Обновляет метрики"""
|
||||
self.total_calls += 1
|
||||
self.total_time += execution_time
|
||||
self.min_time = min(self.min_time, execution_time)
|
||||
self.max_time = max(self.max_time, execution_time)
|
||||
self.last_call = datetime.now()
|
||||
|
||||
|
||||
class TimingStats:
|
||||
"""
|
||||
Глобальная статистика времени выполнения хендлеров.
|
||||
|
||||
Хранит метрики для каждого хендлера и предоставляет методы для анализа.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics: Dict[str, HandlerMetrics] = defaultdict(HandlerMetrics)
|
||||
|
||||
def record(self, handler_name: str, execution_time: float) -> None:
|
||||
"""
|
||||
Записывает время выполнения хендлера.
|
||||
|
||||
Args:
|
||||
handler_name: Имя хендлера
|
||||
execution_time: Время выполнения в секундах
|
||||
"""
|
||||
self.metrics[handler_name].update(execution_time)
|
||||
|
||||
def get_stats(self, handler_name: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает статистику по хендлеру или всем хендлерам.
|
||||
|
||||
Args:
|
||||
handler_name: Имя конкретного хендлера (если None, возвращает все)
|
||||
|
||||
Returns:
|
||||
Dict с метриками
|
||||
"""
|
||||
if handler_name:
|
||||
metrics = self.metrics.get(handler_name)
|
||||
if not metrics:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'handler': handler_name,
|
||||
'total_calls': metrics.total_calls,
|
||||
'avg_time': f"{metrics.avg_time:.3f}s",
|
||||
'min_time': f"{metrics.min_time:.3f}s",
|
||||
'max_time': f"{metrics.max_time:.3f}s",
|
||||
'last_call': metrics.last_call.strftime('%Y-%m-%d %H:%M:%S') if metrics.last_call else None
|
||||
}
|
||||
|
||||
# Возвращаем статистику по всем хендлерам
|
||||
return {
|
||||
name: {
|
||||
'total_calls': m.total_calls,
|
||||
'avg_time': f"{m.avg_time:.3f}s",
|
||||
'min_time': f"{m.min_time:.3f}s",
|
||||
'max_time': f"{m.max_time:.3f}s"
|
||||
}
|
||||
for name, m in sorted(
|
||||
self.metrics.items(),
|
||||
key=lambda x: x[1].avg_time,
|
||||
reverse=True
|
||||
)
|
||||
}
|
||||
|
||||
def get_slowest(self, limit: int = 10) -> list[tuple[str, float]]:
|
||||
"""
|
||||
Возвращает список самых медленных хендлеров.
|
||||
|
||||
Args:
|
||||
limit: Количество хендлеров в результате
|
||||
|
||||
Returns:
|
||||
List кортежей (имя_хендлера, среднее_время)
|
||||
"""
|
||||
sorted_handlers = sorted(
|
||||
self.metrics.items(),
|
||||
key=lambda x: x[1].avg_time,
|
||||
reverse=True
|
||||
)
|
||||
return [(name, m.avg_time) for name, m in sorted_handlers[:limit]]
|
||||
|
||||
def reset(self, handler_name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Сбрасывает статистику.
|
||||
|
||||
Args:
|
||||
handler_name: Имя хендлера для сброса (если None, сбрасывает все)
|
||||
"""
|
||||
if handler_name:
|
||||
if handler_name in self.metrics:
|
||||
del self.metrics[handler_name]
|
||||
else:
|
||||
self.metrics.clear()
|
||||
|
||||
|
||||
# Глобальный экземпляр статистики
|
||||
timing_stats = TimingStats()
|
||||
|
||||
|
||||
class TimingMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для измерения времени выполнения хендлеров.
|
||||
|
||||
Возможности:
|
||||
- Измерение времени выполнения каждого хендлера
|
||||
- Автоматическая классификация (быстрый/средний/медленный)
|
||||
- Сбор статистики
|
||||
- Логирование медленных хендлеров
|
||||
- Предупреждения о критически медленных запросах
|
||||
|
||||
Attributes:
|
||||
slow_threshold: Порог медленного хендлера (сек)
|
||||
warning_threshold: Порог критически медленного хендлера (сек)
|
||||
log_all: Логировать все хендлеры (даже быстрые)
|
||||
collect_stats: Собирать статистику
|
||||
|
||||
Example:
|
||||
```python
|
||||
from middleware.timing import TimingMiddleware, timing_stats
|
||||
|
||||
# Регистрация middleware
|
||||
dp.message.middleware(TimingMiddleware(slow_threshold=0.5))
|
||||
|
||||
# Получение статистики
|
||||
stats = timing_stats.get_slowest(5)
|
||||
for handler, avg_time in stats:
|
||||
print(f"{handler}: {avg_time:.3f}s")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
slow_threshold: float = 1.0,
|
||||
warning_threshold: float = 3.0,
|
||||
log_all: bool = False,
|
||||
collect_stats: bool = True
|
||||
):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
slow_threshold: Порог медленного хендлера в секундах
|
||||
warning_threshold: Порог критически медленного хендлера
|
||||
log_all: Логировать все хендлеры (иначе только медленные)
|
||||
collect_stats: Собирать статистику выполнения
|
||||
"""
|
||||
super().__init__()
|
||||
self.slow_threshold = slow_threshold
|
||||
self.warning_threshold = warning_threshold
|
||||
self.log_all = log_all
|
||||
self.collect_stats = collect_stats
|
||||
|
||||
@staticmethod
|
||||
def _extract_user_info(event: TelegramObject) -> str:
|
||||
"""
|
||||
Извлекает информацию о пользователе из события.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
str: Форматированная строка с информацией о пользователе
|
||||
"""
|
||||
user: Optional[User] = None
|
||||
|
||||
# Прямое извлечение из Message/CallbackQuery
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, 'from_user', None)
|
||||
|
||||
# Извлечение из Update
|
||||
elif isinstance(event, Update):
|
||||
for attr in ['message', 'edited_message', 'callback_query',
|
||||
'channel_post', 'edited_channel_post', 'inline_query',
|
||||
'chosen_inline_result', 'my_chat_member', 'chat_member']:
|
||||
obj = getattr(event, attr, None)
|
||||
if obj and hasattr(obj, 'from_user'):
|
||||
user = obj.from_user
|
||||
break
|
||||
|
||||
if user:
|
||||
return f"@{user.username}" if user.username else f"id{user.id}"
|
||||
|
||||
return "@System"
|
||||
|
||||
@staticmethod
|
||||
def _get_handler_name(handler: Callable) -> str:
|
||||
"""
|
||||
Получает имя хендлера для логирования.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
|
||||
Returns:
|
||||
str: Имя хендлера
|
||||
"""
|
||||
# Пытаемся получить полное имя с модулем
|
||||
if hasattr(handler, '__module__') and hasattr(handler, '__name__'):
|
||||
return f"{handler.__module__}.{handler.__name__}"
|
||||
elif hasattr(handler, '__name__'):
|
||||
return handler.__name__
|
||||
else:
|
||||
return str(handler)
|
||||
|
||||
def _classify_speed(self, execution_time: float) -> tuple[str, str]:
|
||||
"""
|
||||
Классифицирует скорость выполнения.
|
||||
|
||||
Args:
|
||||
execution_time: Время выполнения в секундах
|
||||
|
||||
Returns:
|
||||
tuple: (уровень_лога, тип_лога)
|
||||
"""
|
||||
if execution_time >= self.warning_threshold:
|
||||
return 'ERROR', 'CRITICAL_SLOW'
|
||||
elif execution_time >= self.slow_threshold:
|
||||
return 'WARNING', 'SLOW_HANDLER'
|
||||
elif execution_time >= self.slow_threshold / 2:
|
||||
return 'INFO', 'MEDIUM_HANDLER'
|
||||
else:
|
||||
return 'DEBUG', 'FAST_HANDLER'
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Основной метод middleware.
|
||||
|
||||
Args:
|
||||
handler: Функция хендлера
|
||||
event: Объект события
|
||||
data: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
Результат выполнения хендлера
|
||||
"""
|
||||
start_time = time()
|
||||
handler_name = self._get_handler_name(handler)
|
||||
user_str = self._extract_user_info(event)
|
||||
|
||||
# Выполняем хендлер
|
||||
try:
|
||||
result = await handler(event, data)
|
||||
return result
|
||||
|
||||
finally:
|
||||
# Измеряем время
|
||||
execution_time = time() - start_time
|
||||
|
||||
# Собираем статистику
|
||||
if self.collect_stats:
|
||||
timing_stats.record(handler_name, execution_time)
|
||||
|
||||
# Классифицируем скорость
|
||||
log_level, log_type = self._classify_speed(execution_time)
|
||||
|
||||
# Логируем результат
|
||||
if self.log_all or execution_time >= self.slow_threshold / 2:
|
||||
# Формируем сообщение
|
||||
if execution_time >= self.warning_threshold:
|
||||
message = f"⚠️ КРИТИЧЕСКИ медленный хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
elif execution_time >= self.slow_threshold:
|
||||
message = f"🐌 Медленный хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
else:
|
||||
message = f"⏱️ Хендлер '{handler_name}': {execution_time:.3f}с"
|
||||
|
||||
# Логируем
|
||||
logger.log_entry(
|
||||
level=log_level,
|
||||
text=message,
|
||||
log_type=log_type,
|
||||
user=user_str
|
||||
)
|
||||
1
bot/special/__init__.py
Normal file
1
bot/special/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .text_processing import *
|
||||
290
bot/special/text_processing.py
Normal file
290
bot/special/text_processing.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Утилиты для обработки и нормализации текста.
|
||||
Используется для обнаружения спама и обхода фильтров.
|
||||
|
||||
Pipeline обработки текста:
|
||||
1. unicode_to_ascii() - замена Unicode-символов
|
||||
2. normalize_text() - латиница → кириллица, удаление диакритики
|
||||
3. clean_separators() - удаление разделителей ("г е й" → "гей")
|
||||
4. get_lemma() - получение нормальной формы слова
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Set, List
|
||||
from pymorphy3 import MorphAnalyzer
|
||||
|
||||
from configs.mapping import UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
|
||||
|
||||
__all__ = (
|
||||
"unicode_to_ascii",
|
||||
"normalize_text",
|
||||
"clean_separators",
|
||||
"process_text",
|
||||
"get_lemma",
|
||||
"get_inflected_forms",
|
||||
"morph",
|
||||
"extract_words"
|
||||
)
|
||||
|
||||
# Глобальный экземпляр морфоанализатора (инициализируется один раз)
|
||||
morph = MorphAnalyzer()
|
||||
|
||||
|
||||
def unicode_to_ascii(text: str) -> str:
|
||||
"""
|
||||
Преобразует Unicode-символы в ASCII/кириллические аналоги.
|
||||
|
||||
Args:
|
||||
text: Текст с Unicode-символами
|
||||
|
||||
Returns:
|
||||
str: Текст с нормализованными символами
|
||||
|
||||
Examples:
|
||||
>> unicode_to_ascii("privet")
|
||||
"привет"
|
||||
>> unicode_to_ascii("κупиτь")
|
||||
"купить"
|
||||
>> unicode_to_ascii("𝐡𝐞𝐥𝐥𝐨")
|
||||
"нелло"
|
||||
"""
|
||||
return ''.join(UNICODE_MAP.get(char, char) for char in text)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""
|
||||
Нормализует текст для обхода фильтров:
|
||||
1. Удаляет диакритические знаки (é → e, ė → e)
|
||||
2. Заменяет латинские буквы на кириллические
|
||||
3. Заменяет похожие кириллические буквы (укр/бел) на русские
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
str: Нормализованный текст
|
||||
|
||||
Examples:
|
||||
>> normalize_text("prívét")
|
||||
"привет"
|
||||
>> normalize_text("hеllo") # h - кириллическая
|
||||
"нелло"
|
||||
>> normalize_text("Київ") # і → и
|
||||
"Киев"
|
||||
"""
|
||||
# Шаг 1: Удаляем диакритические знаки (акценты)
|
||||
# NFD разбивает символ на базовый + диакритику
|
||||
text = unicodedata.normalize('NFD', text)
|
||||
# Mn = Mark, Nonspacing (диакритические знаки)
|
||||
text = ''.join(char for char in text if unicodedata.category(char) != 'Mn')
|
||||
# Возвращаем в NFC (композитная форма)
|
||||
text = unicodedata.normalize('NFC', text)
|
||||
|
||||
# Шаг 2: Заменяем латинские → кириллица и нормализуем кириллицу
|
||||
result: List[str] = []
|
||||
for char in text:
|
||||
# Сначала латиница → кириллица
|
||||
if char in LATIN_TO_CYRILLIC:
|
||||
result.append(LATIN_TO_CYRILLIC[char])
|
||||
# Потом нормализуем кириллицу (укр/бел → рус)
|
||||
elif char in CYRILLIC_NORMALIZE:
|
||||
result.append(CYRILLIC_NORMALIZE[char])
|
||||
else:
|
||||
result.append(char)
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def clean_separators(text: str) -> str:
|
||||
"""
|
||||
Удаляет разделители между буквами для обнаружения обхода через пробелы/символы.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
str: Текст без разделителей между буквами
|
||||
|
||||
Examples:
|
||||
>> clean_separators("г е й")
|
||||
"гей"
|
||||
>> clean_separators("г.е.й")
|
||||
"гей"
|
||||
>> clean_separators("г*е*й")
|
||||
"гей"
|
||||
>> clean_separators("к у п и т ь")
|
||||
"купить"
|
||||
>> clean_separators("нормальный текст тут")
|
||||
"нормальный текст тут"
|
||||
"""
|
||||
# Удаляем все НЕ буквенно-цифровые символы, кроме пробелов
|
||||
cleaned: str = re.sub(r'[^\w\s]', '', text, flags=re.UNICODE)
|
||||
|
||||
# Убираем множественные пробелы
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned)
|
||||
|
||||
# Убираем пробелы между отдельными буквами
|
||||
# "г е й" → "гей", но "нормальный текст" остаётся
|
||||
words = cleaned.split()
|
||||
result: List[str] = []
|
||||
temp_chars: List[str] = []
|
||||
|
||||
for word in words:
|
||||
if len(word) == 1:
|
||||
# Одиночный символ - копим
|
||||
temp_chars.append(word)
|
||||
else:
|
||||
# Полное слово - сначала сбрасываем накопленные символы
|
||||
if temp_chars:
|
||||
result.append(''.join(temp_chars))
|
||||
temp_chars = []
|
||||
result.append(word)
|
||||
|
||||
# Не забываем остаток
|
||||
if temp_chars:
|
||||
result.append(''.join(temp_chars))
|
||||
|
||||
return ' '.join(result)
|
||||
|
||||
|
||||
def process_text(text: str, remove_spaces: bool = False) -> str:
|
||||
"""
|
||||
Полный пайплайн обработки текста для спам-фильтра.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
remove_spaces: Удалить все пробелы (для проверки part-слов)
|
||||
|
||||
Returns:
|
||||
str: Обработанный текст в нижнем регистре
|
||||
|
||||
Examples:
|
||||
>> process_text("Κупи*τь сейчас!")
|
||||
"купить сейчас"
|
||||
>> process_text("г е й", remove_spaces=True)
|
||||
"гей"
|
||||
"""
|
||||
# Приводим к нижнему регистру
|
||||
text = text.casefold()
|
||||
|
||||
# Шаг 1: Unicode → ASCII/кириллица
|
||||
text = unicode_to_ascii(text)
|
||||
|
||||
# Шаг 2: Нормализация (латиница → кириллица, диакритика)
|
||||
text = normalize_text(text)
|
||||
|
||||
# Шаг 3: Удаление разделителей
|
||||
text = clean_separators(text)
|
||||
|
||||
# Опционально: удаляем все пробелы (для part-проверки)
|
||||
if remove_spaces:
|
||||
text = re.sub(r'\s+', '', text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def get_lemma(word: str) -> str:
|
||||
"""
|
||||
Получает нормальную форму слова (лемму).
|
||||
|
||||
Args:
|
||||
word: Слово для анализа
|
||||
|
||||
Returns:
|
||||
str: Лемма (нормальная форма)
|
||||
|
||||
Examples:
|
||||
>> get_lemma("купил")
|
||||
"купить"
|
||||
>> get_lemma("карты")
|
||||
"карта"
|
||||
>> get_lemma("хочется")
|
||||
"хотеться"
|
||||
"""
|
||||
try:
|
||||
parsed = morph.parse(word)[0]
|
||||
return parsed.normal_form
|
||||
except (IndexError, Exception):
|
||||
return word
|
||||
|
||||
|
||||
def get_inflected_forms(base_word: str, limit: int = 50) -> Set[str]:
|
||||
"""
|
||||
Получает все словоформы слова через морфологический анализ.
|
||||
|
||||
Args:
|
||||
base_word: Исходное слово
|
||||
limit: Максимальное количество форм (для экономии памяти)
|
||||
|
||||
Returns:
|
||||
Set[str]: Набор всех словоформ (падежи, числа и т.д.)
|
||||
|
||||
Examples:
|
||||
>> get_inflected_forms("купить")
|
||||
{'купить', 'куплю', 'купишь', 'купит', ...}
|
||||
>> get_inflected_forms("карта")
|
||||
{'карта', 'карты', 'карте', 'карту', ...}
|
||||
"""
|
||||
try:
|
||||
parsed = morph.parse(base_word)[0]
|
||||
forms: Set[str] = set()
|
||||
|
||||
for form in parsed.lexeme:
|
||||
if len(forms) >= limit:
|
||||
break
|
||||
forms.add(form.normal_form)
|
||||
forms.add(form.word)
|
||||
|
||||
return forms
|
||||
except Exception:
|
||||
return {base_word}
|
||||
|
||||
|
||||
def extract_words(text: str) -> List[str]:
|
||||
"""
|
||||
Извлекает слова из текста (только буквы).
|
||||
|
||||
Args:
|
||||
text: Текст для обработки
|
||||
|
||||
Returns:
|
||||
List[str]: Список слов
|
||||
|
||||
Examples:
|
||||
>> extract_words("Привет, как дела?")
|
||||
['Привет', 'как', 'дела']
|
||||
"""
|
||||
return re.findall(r'\b\w+\b', text, flags=re.UNICODE)
|
||||
|
||||
|
||||
def calculate_similarity(text1: str, text2: str) -> float:
|
||||
"""
|
||||
Вычисляет схожесть двух текстов (простая метрика).
|
||||
|
||||
Args:
|
||||
text1: Первый текст
|
||||
text2: Второй текст
|
||||
|
||||
Returns:
|
||||
float: Коэффициент схожести (0.0 - 1.0)
|
||||
|
||||
Examples:
|
||||
>> calculate_similarity("привет", "привет")
|
||||
1.0
|
||||
>> calculate_similarity("купить", "продать")
|
||||
0.0
|
||||
"""
|
||||
processed1 = process_text(text1)
|
||||
processed2 = process_text(text2)
|
||||
|
||||
if processed1 == processed2:
|
||||
return 1.0
|
||||
|
||||
# Levenshtein distance (простой вариант)
|
||||
len1, len2 = len(processed1), len(processed2)
|
||||
if len1 == 0 or len2 == 0:
|
||||
return 0.0
|
||||
|
||||
# Считаем совпадающие символы
|
||||
matches = sum(1 for a, b in zip(processed1, processed2) if a == b)
|
||||
return matches / max(len1, len2)
|
||||
0
bot/states/__init__.py
Normal file
0
bot/states/__init__.py
Normal file
1
bot/templates/__init__.py
Normal file
1
bot/templates/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .message_callback import *
|
||||
818
bot/templates/message_callback.py
Normal file
818
bot/templates/message_callback.py
Normal file
@@ -0,0 +1,818 @@
|
||||
"""
|
||||
Универсальные шаблоны для отправки сообщений
|
||||
"""
|
||||
from typing import Union, Optional, List, Dict, Callable
|
||||
from pathlib import Path
|
||||
from contextlib import suppress
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
Message,
|
||||
CallbackQuery,
|
||||
InlineKeyboardMarkup,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
FSInputFile,
|
||||
InputMediaPhoto,
|
||||
InputMediaVideo,
|
||||
InputMediaAudio,
|
||||
InputMediaDocument,
|
||||
BufferedInputFile
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.enums import ParseMode, ChatAction
|
||||
|
||||
from middleware.loggers import logger
|
||||
from ..utils.state_utils import safe_answer_callback
|
||||
from ..utils.auto_delete import auto_delete_manager
|
||||
|
||||
__all__ = (
|
||||
'msg',
|
||||
'msg_photo',
|
||||
'msg_video',
|
||||
'msg_document',
|
||||
'msg_audio',
|
||||
'msg_voice',
|
||||
'msg_media_group',
|
||||
'edit_msg',
|
||||
'delete_msg',
|
||||
'forward_msg',
|
||||
'send_action',
|
||||
'markups',
|
||||
'MessageTemplate',
|
||||
'batch_send'
|
||||
)
|
||||
|
||||
|
||||
class MessageTemplate:
|
||||
"""
|
||||
Класс для хранения шаблонов сообщений.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Создание шаблона
|
||||
welcome = MessageTemplate(
|
||||
text="👋 Привет, {name}! Добро пожаловать в {chat}",
|
||||
parse_mode=ParseMode.HTML
|
||||
)
|
||||
|
||||
# Использование
|
||||
await welcome.send(
|
||||
message,
|
||||
name=user.first_name,
|
||||
chat=chat.title
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
parse_mode: Optional[str] = ParseMode.HTML,
|
||||
disable_web_page_preview: bool = False,
|
||||
markup: Optional[Union[InlineKeyboardBuilder, InlineKeyboardMarkup]] = None
|
||||
):
|
||||
self.text = text
|
||||
self.parse_mode = parse_mode
|
||||
self.disable_web_page_preview = disable_web_page_preview
|
||||
self.markup = markup
|
||||
|
||||
def format(self, **kwargs) -> str:
|
||||
"""Форматирует текст с подстановкой переменных"""
|
||||
return self.text.format(**kwargs)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
target: Union[Message, CallbackQuery, int],
|
||||
bot: Optional[Bot] = None,
|
||||
**format_kwargs
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Отправляет сообщение по шаблону.
|
||||
|
||||
Args:
|
||||
target: Куда отправить (Message, CallbackQuery или chat_id)
|
||||
bot: Экземпляр бота (если target это chat_id)
|
||||
**format_kwargs: Переменные для форматирования
|
||||
"""
|
||||
text = self.format(**format_kwargs)
|
||||
|
||||
if isinstance(target, int):
|
||||
# Отправка по chat_id
|
||||
if not bot:
|
||||
raise ValueError("Bot instance required for chat_id")
|
||||
|
||||
return await bot.send_message(
|
||||
chat_id=target,
|
||||
text=text,
|
||||
parse_mode=self.parse_mode,
|
||||
disable_web_page_preview=self.disable_web_page_preview,
|
||||
reply_markup=markups(self.markup)
|
||||
)
|
||||
|
||||
else:
|
||||
# Отправка через Message/CallbackQuery
|
||||
return await msg(
|
||||
target,
|
||||
text=text,
|
||||
parse_mode=self.parse_mode,
|
||||
disable_web_page_preview=self.disable_web_page_preview,
|
||||
markup=self.markup
|
||||
)
|
||||
|
||||
|
||||
# ================= MARKUP UTILS =================
|
||||
|
||||
def markups(
|
||||
markup: Union[
|
||||
InlineKeyboardBuilder,
|
||||
ReplyKeyboardBuilder,
|
||||
InlineKeyboardMarkup,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
None
|
||||
] = None,
|
||||
resize_keyboard: bool = True,
|
||||
one_time_keyboard: bool = False
|
||||
) -> Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove]]:
|
||||
"""
|
||||
Конвертирует builder в готовый markup.
|
||||
|
||||
Args:
|
||||
markup: Builder или готовая клавиатура
|
||||
resize_keyboard: Автоматический размер (для ReplyKeyboard)
|
||||
one_time_keyboard: Скрыть после нажатия (для ReplyKeyboard)
|
||||
|
||||
Returns:
|
||||
Готовый markup или None
|
||||
|
||||
Example:
|
||||
>> builder = InlineKeyboardBuilder()
|
||||
>> builder.button(text="Test", callback_data="test")
|
||||
>> keyboard = markups(builder)
|
||||
"""
|
||||
if markup is None:
|
||||
return None
|
||||
|
||||
if isinstance(markup, InlineKeyboardBuilder):
|
||||
return markup.as_markup()
|
||||
|
||||
if isinstance(markup, ReplyKeyboardBuilder):
|
||||
return markup.as_markup(
|
||||
resize_keyboard=resize_keyboard,
|
||||
one_time_keyboard=one_time_keyboard
|
||||
)
|
||||
|
||||
if isinstance(markup, (InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove)):
|
||||
return markup
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ================= TEXT MESSAGES =================
|
||||
|
||||
async def msg(
|
||||
update: Union[Message, CallbackQuery],
|
||||
text: str,
|
||||
state: Optional[FSMContext] = None,
|
||||
markup: Union[InlineKeyboardBuilder, InlineKeyboardMarkup, None] = None,
|
||||
parse_mode: Optional[str] = ParseMode.HTML,
|
||||
disable_web_page_preview: bool = False,
|
||||
answer_callback: bool = True,
|
||||
state_clear: bool = False,
|
||||
edit_if_possible: bool = True,
|
||||
delete_previous: bool = False,
|
||||
auto_delete: Optional[int] = None,
|
||||
disable_notification: bool = False,
|
||||
protect_content: bool = False,
|
||||
show_typing: bool = False,
|
||||
log: bool = False
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Универсальная отправка/редактирование текстового сообщения.
|
||||
|
||||
Args:
|
||||
update: Message или CallbackQuery
|
||||
text: Текст сообщения
|
||||
state: FSM контекст
|
||||
markup: Клавиатура
|
||||
parse_mode: Режим парсинга (HTML, Markdown, None)
|
||||
disable_web_page_preview: Отключить предпросмотр ссылок
|
||||
answer_callback: Ответить на callback
|
||||
state_clear: Очистить состояние
|
||||
edit_if_possible: Попытаться отредактировать (для callback)
|
||||
delete_previous: Удалить предыдущее сообщение перед отправкой
|
||||
auto_delete: Автоудаление через N секунд
|
||||
disable_notification: Без звука
|
||||
protect_content: Защита от пересылки
|
||||
show_typing: Показать "печатает"
|
||||
log: Логировать отправку
|
||||
|
||||
Returns:
|
||||
Отправленное сообщение
|
||||
|
||||
Example:
|
||||
>> # Простая отправка
|
||||
>> await msg(message, "Привет!")
|
||||
|
||||
>> # С клавиатурой и автоудалением
|
||||
>> builder = InlineKeyboardBuilder()
|
||||
>> builder.button(text="OK", callback_data="ok")
|
||||
>> await msg(
|
||||
... callback,
|
||||
... "Сообщение удалится через 10 секунд",
|
||||
... markup=builder,
|
||||
... auto_delete=10
|
||||
... )
|
||||
"""
|
||||
# Получаем message объект
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
logger.warning("Невозможно получить message объект", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
# Показываем typing если нужно
|
||||
if show_typing:
|
||||
await send_action(message, ChatAction.TYPING)
|
||||
|
||||
# Удаляем предыдущее сообщение если нужно
|
||||
if delete_previous:
|
||||
with suppress(TelegramBadRequest, TelegramForbiddenError):
|
||||
await message.delete()
|
||||
|
||||
keyboard = markups(markup)
|
||||
|
||||
try:
|
||||
# Попытка редактирования (для callback)
|
||||
if edit_if_possible and isinstance(update, CallbackQuery):
|
||||
sent_message = await message.edit_text(
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_web_page_preview
|
||||
)
|
||||
|
||||
if log:
|
||||
logger.debug(
|
||||
f"Сообщение отредактировано: {message.message_id}",
|
||||
log_type='MESSAGE'
|
||||
)
|
||||
else:
|
||||
raise TelegramBadRequest
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
# Отправка нового сообщения
|
||||
try:
|
||||
sent_message = await message.answer(
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
disable_notification=disable_notification,
|
||||
protect_content=protect_content
|
||||
)
|
||||
|
||||
if log:
|
||||
logger.debug(
|
||||
f"Сообщение отправлено: {sent_message.message_id}",
|
||||
log_type='MESSAGE'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
# Отвечаем на callback
|
||||
if answer_callback and isinstance(update, CallbackQuery):
|
||||
await safe_answer_callback(update)
|
||||
|
||||
# Очищаем состояние
|
||||
if state_clear and state:
|
||||
await state.clear()
|
||||
|
||||
# Планируем автоудаление
|
||||
if auto_delete and sent_message:
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=sent_message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
delay=auto_delete,
|
||||
reason="template_auto_delete"
|
||||
)
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
# ================= MEDIA MESSAGES =================
|
||||
|
||||
async def msg_photo(
|
||||
update: Union[Message, CallbackQuery],
|
||||
photo: Union[str, Path, FSInputFile, BufferedInputFile],
|
||||
caption: Optional[str] = None,
|
||||
state: Optional[FSMContext] = None,
|
||||
markup: Union[InlineKeyboardBuilder, InlineKeyboardMarkup, None] = None,
|
||||
parse_mode: Optional[str] = ParseMode.HTML,
|
||||
answer_callback: bool = True,
|
||||
state_clear: bool = False,
|
||||
edit_if_possible: bool = True,
|
||||
auto_delete: Optional[int] = None,
|
||||
has_spoiler: bool = False,
|
||||
log: bool = False
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Универсальная отправка/редактирование фото.
|
||||
|
||||
Args:
|
||||
update: Message или CallbackQuery
|
||||
photo: Путь к файлу, FSInputFile или BufferedInputFile
|
||||
caption: Подпись к фото
|
||||
state: FSM контекст
|
||||
markup: Клавиатура
|
||||
parse_mode: Режим парсинга
|
||||
answer_callback: Ответить на callback
|
||||
state_clear: Очистить состояние
|
||||
edit_if_possible: Попытаться отредактировать
|
||||
auto_delete: Автоудаление через N секунд
|
||||
has_spoiler: Спойлер
|
||||
log: Логировать
|
||||
|
||||
Returns:
|
||||
Отправленное сообщение
|
||||
|
||||
Example:
|
||||
>> await msg_photo(
|
||||
... message,
|
||||
... photo="assets/welcome.jpg",
|
||||
... caption="Добро пожаловать!",
|
||||
... auto_delete=30
|
||||
... )
|
||||
"""
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
# Конвертируем путь в FSInputFile
|
||||
if isinstance(photo, (str, Path)):
|
||||
photo = FSInputFile(photo)
|
||||
|
||||
keyboard = markups(markup)
|
||||
|
||||
try:
|
||||
# Попытка редактирования медиа
|
||||
if edit_if_possible and isinstance(update, CallbackQuery):
|
||||
media = InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=caption,
|
||||
parse_mode=parse_mode,
|
||||
has_spoiler=has_spoiler
|
||||
)
|
||||
|
||||
await message.edit_media(
|
||||
media=media,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
sent_message = message
|
||||
|
||||
if log:
|
||||
logger.debug("Фото отредактировано", log_type='MESSAGE')
|
||||
else:
|
||||
raise TelegramBadRequest
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
# Отправка нового фото
|
||||
try:
|
||||
sent_message = await message.answer_photo(
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
has_spoiler=has_spoiler
|
||||
)
|
||||
|
||||
if log:
|
||||
logger.debug("Фото отправлено", log_type='MESSAGE')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
if answer_callback and isinstance(update, CallbackQuery):
|
||||
await safe_answer_callback(update)
|
||||
|
||||
if state_clear and state:
|
||||
await state.clear()
|
||||
|
||||
if auto_delete and sent_message:
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=sent_message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
delay=auto_delete
|
||||
)
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
async def msg_video(
|
||||
update: Union[Message, CallbackQuery],
|
||||
video: Union[str, Path, FSInputFile, BufferedInputFile],
|
||||
caption: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Отправка видео.
|
||||
|
||||
Поддерживает те же параметры что и msg_photo.
|
||||
"""
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if isinstance(video, (str, Path)):
|
||||
video = FSInputFile(video)
|
||||
|
||||
try:
|
||||
sent = await message.answer_video(
|
||||
video=video,
|
||||
caption=caption,
|
||||
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
|
||||
reply_markup=markups(kwargs.get('markup'))
|
||||
)
|
||||
|
||||
if kwargs.get('answer_callback') and isinstance(update, CallbackQuery):
|
||||
await safe_answer_callback(update)
|
||||
|
||||
if kwargs.get('state_clear') and kwargs.get('state'):
|
||||
await kwargs['state'].clear()
|
||||
|
||||
if kwargs.get('auto_delete'):
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=sent.chat.id,
|
||||
message_id=sent.message_id,
|
||||
delay=kwargs['auto_delete']
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки видео: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
async def msg_document(
|
||||
update: Union[Message, CallbackQuery],
|
||||
document: Union[str, Path, FSInputFile, BufferedInputFile],
|
||||
caption: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Отправка документа.
|
||||
|
||||
Args:
|
||||
filename: Имя файла для отображения
|
||||
:param filename:
|
||||
:param caption:
|
||||
:param document:
|
||||
:param update:
|
||||
"""
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if isinstance(document, (str, Path)):
|
||||
document = FSInputFile(document, filename=filename)
|
||||
|
||||
try:
|
||||
sent = await message.answer_document(
|
||||
document=document,
|
||||
caption=caption,
|
||||
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
|
||||
reply_markup=markups(kwargs.get('markup'))
|
||||
)
|
||||
|
||||
if kwargs.get('answer_callback') and isinstance(update, CallbackQuery):
|
||||
await safe_answer_callback(update)
|
||||
|
||||
if kwargs.get('state_clear') and kwargs.get('state'):
|
||||
await kwargs['state'].clear()
|
||||
|
||||
if kwargs.get('auto_delete'):
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=sent.chat.id,
|
||||
message_id=sent.message_id,
|
||||
delay=kwargs['auto_delete']
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки документа: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
async def msg_audio(
|
||||
update: Union[Message, CallbackQuery],
|
||||
audio: Union[str, Path, FSInputFile, BufferedInputFile],
|
||||
caption: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Optional[Message]:
|
||||
"""Отправка аудио"""
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if isinstance(audio, (str, Path)):
|
||||
audio = FSInputFile(audio)
|
||||
|
||||
try:
|
||||
return await message.answer_audio(
|
||||
audio=audio,
|
||||
caption=caption,
|
||||
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
|
||||
reply_markup=markups(kwargs.get('markup'))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки аудио: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
async def msg_voice(
|
||||
update: Union[Message, CallbackQuery],
|
||||
voice: Union[str, Path, FSInputFile, BufferedInputFile],
|
||||
caption: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Optional[Message]:
|
||||
"""Отправка голосового сообщения"""
|
||||
message = update.message if isinstance(update, CallbackQuery) else update
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if isinstance(voice, (str, Path)):
|
||||
voice = FSInputFile(voice)
|
||||
|
||||
try:
|
||||
return await message.answer_voice(
|
||||
voice=voice,
|
||||
caption=caption,
|
||||
parse_mode=kwargs.get('parse_mode', ParseMode.HTML),
|
||||
reply_markup=markups(kwargs.get('markup'))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки голосового: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
async def msg_media_group(
|
||||
message: Message,
|
||||
media: List[Union[InputMediaPhoto, InputMediaVideo, InputMediaAudio, InputMediaDocument]],
|
||||
caption: Optional[str] = None
|
||||
) -> Optional[List[Message]]:
|
||||
"""
|
||||
Отправка media group (альбом).
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
media: Список медиа
|
||||
caption: Подпись (будет добавлена к первому элементу)
|
||||
|
||||
Returns:
|
||||
Список отправленных сообщений
|
||||
|
||||
Example:
|
||||
>> media = [
|
||||
... InputMediaPhoto(media=FSInputFile("photo1.jpg")),
|
||||
... InputMediaPhoto(media=FSInputFile("photo2.jpg")),
|
||||
... InputMediaVideo(media=FSInputFile("video.mp4"))
|
||||
... ]
|
||||
>> await msg_media_group(message, media, caption="Альбом")
|
||||
"""
|
||||
if not media:
|
||||
return None
|
||||
|
||||
# Добавляем подпись к первому элементу
|
||||
if caption and media:
|
||||
media[0].caption = caption
|
||||
|
||||
try:
|
||||
return await message.answer_media_group(media=media)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки media group: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
# ================= MESSAGE ACTIONS =================
|
||||
|
||||
async def edit_msg(
|
||||
message: Message,
|
||||
text: Optional[str] = None,
|
||||
caption: Optional[str] = None,
|
||||
markup: Optional[InlineKeyboardMarkup] = None,
|
||||
parse_mode: Optional[str] = ParseMode.HTML
|
||||
) -> bool:
|
||||
"""
|
||||
Безопасное редактирование сообщения.
|
||||
|
||||
Returns:
|
||||
bool: True если успешно отредактировано
|
||||
"""
|
||||
try:
|
||||
if text:
|
||||
await message.edit_text(
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
elif caption:
|
||||
await message.edit_caption(
|
||||
caption=caption,
|
||||
reply_markup=markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
await message.edit_reply_markup(reply_markup=markup)
|
||||
|
||||
return True
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
logger.debug(f"Не удалось отредактировать сообщение: {e}", log_type='MESSAGE')
|
||||
return False
|
||||
|
||||
|
||||
async def delete_msg(
|
||||
message: Message,
|
||||
delay: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Безопасное удаление сообщения.
|
||||
|
||||
Args:
|
||||
message: Сообщение для удаления
|
||||
delay: Задержка перед удалением (секунды)
|
||||
|
||||
Returns:
|
||||
bool: True если успешно удалено
|
||||
"""
|
||||
if delay:
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
delay=delay
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
return True
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
async def forward_msg(
|
||||
message: Message,
|
||||
to_chat_id: int,
|
||||
disable_notification: bool = False,
|
||||
protect_content: bool = False
|
||||
) -> Optional[Message]:
|
||||
"""
|
||||
Пересылка сообщения.
|
||||
|
||||
Args:
|
||||
message: Исходное сообщение
|
||||
to_chat_id: ID чата куда переслать
|
||||
disable_notification: Без звука
|
||||
protect_content: Защита от пересылки
|
||||
|
||||
Returns:
|
||||
Пересланное сообщение
|
||||
"""
|
||||
try:
|
||||
return await message.forward(
|
||||
chat_id=to_chat_id,
|
||||
disable_notification=disable_notification,
|
||||
protect_content=protect_content
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пересылки сообщения: {e}", log_type='MESSAGE')
|
||||
return None
|
||||
|
||||
|
||||
async def send_action(
|
||||
message: Message,
|
||||
action: ChatAction = ChatAction.TYPING
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка chat action (печатает, загружает фото и т.д.).
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
action: Тип действия
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
|
||||
Example:
|
||||
>> await send_action(message, ChatAction.TYPING)
|
||||
>> await send_action(message, ChatAction.UPLOAD_PHOTO)
|
||||
"""
|
||||
try:
|
||||
await message.bot.send_chat_action(
|
||||
chat_id=message.chat.id,
|
||||
action=action
|
||||
)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
# ================= BATCH SENDING =================
|
||||
|
||||
async def batch_send(
|
||||
bot: Bot,
|
||||
chat_ids: List[int],
|
||||
text: str,
|
||||
markup: Optional[InlineKeyboardMarkup] = None,
|
||||
parse_mode: Optional[str] = ParseMode.HTML,
|
||||
disable_notification: bool = False,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_error: Optional[Callable] = None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Массовая рассылка сообщений.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_ids: Список ID чатов
|
||||
text: Текст сообщения
|
||||
markup: Клавиатура
|
||||
parse_mode: Режим парсинга
|
||||
disable_notification: Без звука
|
||||
on_success: Callback при успехе (chat_id)
|
||||
on_error: Callback при ошибке (chat_id, error)
|
||||
|
||||
Returns:
|
||||
Dict со статистикой: {'success': N, 'failed': N}
|
||||
|
||||
Example:
|
||||
>> stats = await batch_send(
|
||||
... bot,
|
||||
... [123, 456, 789],
|
||||
... "Важное объявление!"
|
||||
... )
|
||||
>> print(f"Отправлено: {stats['success']}")
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for chat_id in chat_ids:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
parse_mode=parse_mode,
|
||||
disable_notification=disable_notification
|
||||
)
|
||||
|
||||
success_count += 1
|
||||
|
||||
if on_success:
|
||||
await on_success(chat_id)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
|
||||
if on_error:
|
||||
await on_error(chat_id, e)
|
||||
|
||||
logger.warning(
|
||||
f"Не удалось отправить сообщение в чат {chat_id}: {e}",
|
||||
log_type='BATCH'
|
||||
)
|
||||
|
||||
# Небольшая задержка чтобы избежать rate limit
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
logger.info(
|
||||
f"Рассылка завершена: успешно={success_count}, ошибок={failed_count}",
|
||||
log_type='BATCH'
|
||||
)
|
||||
|
||||
return {
|
||||
'success': success_count,
|
||||
'failed': failed_count
|
||||
}
|
||||
38
bot/utils/__init__.py
Normal file
38
bot/utils/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Утилиты бота PrimoGuardBot
|
||||
|
||||
Модули:
|
||||
- usernames: Работа с пользователями (username, mentions, display names)
|
||||
- type_message: Типы контента и чатов
|
||||
- hidden_username: Упоминания администраторов
|
||||
- format_time: Форматирование времени и дат
|
||||
- argument: Парсинг команд и аргументов
|
||||
- state_utils: Работа с FSM состояниями
|
||||
- auto_delete: Автоматическое удаление сообщений
|
||||
- decorators: Декораторы для хендлеров
|
||||
"""
|
||||
|
||||
# ================= USER INFO =================
|
||||
from .usernames import *
|
||||
|
||||
# ================= CONTENT TYPES =================
|
||||
from .type_message import *
|
||||
|
||||
# ================= MENTIONS =================
|
||||
from .hidden_username import *
|
||||
|
||||
# ================= TIME FORMATTING =================
|
||||
from .format_time import *
|
||||
|
||||
# ================= COMMANDS =================
|
||||
from .argument import *
|
||||
|
||||
# ================= STATE UTILS =================
|
||||
from .state_utils import *
|
||||
|
||||
# ================= AUTO DELETE =================
|
||||
from .auto_delete import *
|
||||
|
||||
# ================= DECORATORS =================
|
||||
from .decorators import *
|
||||
|
||||
688
bot/utils/argument.py
Normal file
688
bot/utils/argument.py
Normal file
@@ -0,0 +1,688 @@
|
||||
"""
|
||||
Утилиты для работы с командами бота
|
||||
"""
|
||||
from typing import Optional, Union, Dict, List, Tuple, Set
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
|
||||
from aiogram.types import Message
|
||||
|
||||
from configs import settings
|
||||
|
||||
__all__ = (
|
||||
'is_command',
|
||||
'find_argument',
|
||||
'get_command',
|
||||
'parse_arguments',
|
||||
'parse_flags',
|
||||
'CommandParser',
|
||||
'ParsedCommand',
|
||||
'parse_command',
|
||||
'validate_command',
|
||||
'get_command_usage',
|
||||
'extract_mentions',
|
||||
'extract_user_ids',
|
||||
'extract_hashtags'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCommand:
|
||||
"""
|
||||
Распарсенная команда.
|
||||
|
||||
Attributes:
|
||||
command: Название команды
|
||||
prefix: Префикс команды
|
||||
args: Список аргументов
|
||||
raw_args: Исходная строка аргументов
|
||||
flags: Словарь флагов (--flag value)
|
||||
bot_username: Username бота (если было упоминание)
|
||||
is_group_command: Команда в группе с упоминанием бота
|
||||
"""
|
||||
command: str
|
||||
prefix: str
|
||||
args: List[str] = field(default_factory=list)
|
||||
raw_args: Optional[str] = None
|
||||
flags: Dict[str, Union[str, bool]] = field(default_factory=dict)
|
||||
bot_username: Optional[str] = None
|
||||
is_group_command: bool = False
|
||||
|
||||
@property
|
||||
def has_args(self) -> bool:
|
||||
"""Есть ли аргументы"""
|
||||
return len(self.args) > 0
|
||||
|
||||
@property
|
||||
def has_flags(self) -> bool:
|
||||
"""Есть ли флаги"""
|
||||
return len(self.flags) > 0
|
||||
|
||||
def get_arg(self, index: int, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Получает аргумент по индексу"""
|
||||
return self.args[index] if index < len(self.args) else default
|
||||
|
||||
def get_flag(self, name: str, default: Optional[Union[str, bool]] = None) -> Union[str, bool, None]:
|
||||
"""Получает значение флага"""
|
||||
return self.flags.get(name, default)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"ParsedCommand(command='{self.command}', "
|
||||
f"args={self.args}, flags={self.flags})"
|
||||
)
|
||||
|
||||
|
||||
class CommandParser:
|
||||
"""
|
||||
Парсер команд бота.
|
||||
|
||||
Возможности:
|
||||
- Поддержка нескольких префиксов
|
||||
- Парсинг аргументов
|
||||
- Парсинг флагов (--flag value, -f value)
|
||||
- Поддержка упоминаний бота (@botname)
|
||||
- Парсинг quoted аргументов ("arg with spaces")
|
||||
- Валидация команд
|
||||
|
||||
Example:
|
||||
```python
|
||||
parser = CommandParser()
|
||||
|
||||
# Парсинг команды
|
||||
parsed = parser.parse("/ban @user 7d --reason спам")
|
||||
print(parsed.command) # "ban"
|
||||
print(parsed.args) # ["@user", "7d"]
|
||||
print(parsed.flags) # {"reason": "спам"}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefixes: Optional[List[str]] = None,
|
||||
bot_username: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
prefixes: Список префиксов (по умолчанию из settings)
|
||||
bot_username: Username бота для проверки упоминаний
|
||||
"""
|
||||
self.prefixes = prefixes or settings.PREFIX
|
||||
self.bot_username = bot_username
|
||||
|
||||
def is_command(self, text: Optional[str]) -> bool:
|
||||
"""
|
||||
Проверяет, является ли текст командой.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
bool: True если это команда
|
||||
|
||||
Example:
|
||||
>> parser.is_command("/start")
|
||||
True
|
||||
>> parser.is_command("hello")
|
||||
False
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Проверяем все префиксы
|
||||
return any(text.startswith(prefix) for prefix in self.prefixes)
|
||||
|
||||
def get_command(
|
||||
self,
|
||||
text: Optional[str],
|
||||
strip_mention: bool = True
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Извлекает название команды из текста.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
strip_mention: Убирать упоминание бота (@botname)
|
||||
|
||||
Returns:
|
||||
Optional[str]: Название команды или None
|
||||
|
||||
Example:
|
||||
>> parser.get_command("/start@mybot arg")
|
||||
'start'
|
||||
>> parser.get_command("!help")
|
||||
'help'
|
||||
"""
|
||||
if not self.is_command(text):
|
||||
return None
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Находим префикс
|
||||
prefix = next(p for p in self.prefixes if text.startswith(p))
|
||||
|
||||
# Убираем префикс
|
||||
without_prefix = text[len(prefix):]
|
||||
|
||||
# Берем первое слово
|
||||
command = without_prefix.split()[0] if without_prefix else ""
|
||||
|
||||
# Убираем упоминание бота если есть
|
||||
if strip_mention and '@' in command:
|
||||
command = command.split('@')[0]
|
||||
|
||||
return command.lower() if command else None
|
||||
|
||||
def find_argument(self, text: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Извлекает аргументы команды (все после команды).
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Optional[str]: Аргументы или None
|
||||
|
||||
Example:
|
||||
>> parser.find_argument("/start referrer")
|
||||
'referrer'
|
||||
>> parser.find_argument("/ban @user reason text")
|
||||
'@user reason text'
|
||||
"""
|
||||
if not self.is_command(text):
|
||||
return None
|
||||
|
||||
parts = text.strip().split(maxsplit=1)
|
||||
return parts[1] if len(parts) > 1 else None
|
||||
|
||||
@staticmethod
|
||||
def parse_arguments(
|
||||
args_text: Optional[str],
|
||||
preserve_quotes: bool = False
|
||||
) -> List[str]:
|
||||
"""
|
||||
Парсит аргументы, поддерживает кавычки.
|
||||
|
||||
Args:
|
||||
args_text: Строка аргументов
|
||||
preserve_quotes: Сохранять кавычки в результате
|
||||
|
||||
Returns:
|
||||
List[str]: Список аргументов
|
||||
|
||||
Example:
|
||||
>> parser.parse_arguments('user 7d "ban reason here"')
|
||||
['user', '7d', 'ban reason here']
|
||||
"""
|
||||
if not args_text:
|
||||
return []
|
||||
|
||||
# Regex для парсинга с кавычками
|
||||
# Поддерживает: "arg with spaces" 'arg' arg
|
||||
pattern = r'''(?:[^\s"']+|"[^"]*"|'[^']*')+'''
|
||||
matches = re.findall(pattern, args_text)
|
||||
|
||||
if preserve_quotes:
|
||||
return matches
|
||||
|
||||
# Убираем кавычки
|
||||
return [m.strip('"').strip("'") for m in matches]
|
||||
|
||||
@staticmethod
|
||||
def parse_flags(
|
||||
args: List[str]
|
||||
) -> Tuple[List[str], Dict[str, Union[str, bool]]]:
|
||||
"""
|
||||
Парсит флаги из аргументов.
|
||||
|
||||
Поддерживает:
|
||||
- --flag value
|
||||
- --flag (boolean, True)
|
||||
- -f value (короткая форма)
|
||||
|
||||
Args:
|
||||
args: Список аргументов
|
||||
|
||||
Returns:
|
||||
Tuple: (аргументы_без_флагов, словарь_флагов)
|
||||
|
||||
Example:
|
||||
>> args = ['user', '--reason', 'spam', '--silent']
|
||||
>> clean_args, flags = parser.parse_flags(args)
|
||||
>> print(clean_args) # ['user']
|
||||
>> print(flags) # {'reason': 'spam', 'silent': True}
|
||||
"""
|
||||
clean_args = []
|
||||
flags = {}
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
# Длинный флаг --flag
|
||||
if arg.startswith('--'):
|
||||
flag_name = arg[2:]
|
||||
|
||||
# Проверяем, есть ли значение
|
||||
if i + 1 < len(args) and not args[i + 1].startswith('-'):
|
||||
flags[flag_name] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
# Boolean флаг
|
||||
flags[flag_name] = True
|
||||
i += 1
|
||||
|
||||
# Короткий флаг -f
|
||||
elif arg.startswith('-') and len(arg) == 2:
|
||||
flag_name = arg[1]
|
||||
|
||||
# Проверяем значение
|
||||
if i + 1 < len(args) and not args[i + 1].startswith('-'):
|
||||
flags[flag_name] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
flags[flag_name] = True
|
||||
i += 1
|
||||
|
||||
# Обычный аргумент
|
||||
else:
|
||||
clean_args.append(arg)
|
||||
i += 1
|
||||
|
||||
return clean_args, flags
|
||||
|
||||
def parse(
|
||||
self,
|
||||
text: str,
|
||||
parse_flags: bool = True
|
||||
) -> Optional[ParsedCommand]:
|
||||
"""
|
||||
Полный парсинг команды.
|
||||
|
||||
Args:
|
||||
text: Текст команды
|
||||
parse_flags: Парсить флаги
|
||||
|
||||
Returns:
|
||||
Optional[ParsedCommand]: Распарсенная команда или None
|
||||
|
||||
Example:
|
||||
>> parsed = parser.parse('/ban @user 7d --reason "spam bot"')
|
||||
>> print(parsed.command) # 'ban'
|
||||
>> print(parsed.args) # ['@user', '7d']
|
||||
>> print(parsed.flags) # {'reason': 'spam bot'}
|
||||
"""
|
||||
if not self.is_command(text):
|
||||
return None
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Находим префикс
|
||||
prefix = next(p for p in self.prefixes if text.startswith(p))
|
||||
|
||||
# Убираем префикс
|
||||
without_prefix = text[len(prefix):]
|
||||
|
||||
# Разделяем на команду и аргументы
|
||||
parts = without_prefix.split(maxsplit=1)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
command_part = parts[0]
|
||||
raw_args = parts[1] if len(parts) > 1 else None
|
||||
|
||||
# Проверяем упоминание бота
|
||||
bot_username = None
|
||||
is_group_command = False
|
||||
|
||||
if '@' in command_part:
|
||||
cmd_parts = command_part.split('@')
|
||||
command_name = cmd_parts[0]
|
||||
bot_username = cmd_parts[1] if len(cmd_parts) > 1 else None
|
||||
is_group_command = True
|
||||
else:
|
||||
command_name = command_part
|
||||
|
||||
# Парсим аргументы
|
||||
args = self.parse_arguments(raw_args) if raw_args else []
|
||||
|
||||
# Парсим флаги
|
||||
flags = {}
|
||||
if parse_flags and args:
|
||||
args, flags = self.parse_flags(args)
|
||||
|
||||
return ParsedCommand(
|
||||
command=command_name.lower(),
|
||||
prefix=prefix,
|
||||
args=args,
|
||||
raw_args=raw_args,
|
||||
flags=flags,
|
||||
bot_username=bot_username,
|
||||
is_group_command=is_group_command
|
||||
)
|
||||
|
||||
def parse_from_message(
|
||||
self,
|
||||
message: Message,
|
||||
parse_flags: bool = True
|
||||
) -> Optional[ParsedCommand]:
|
||||
"""
|
||||
Парсит команду из объекта Message.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
parse_flags: Парсить флаги
|
||||
|
||||
Returns:
|
||||
Optional[ParsedCommand]: Распарсенная команда
|
||||
|
||||
Example:
|
||||
>> parsed = parser.parse_from_message(message)
|
||||
>> if parsed:
|
||||
... print(f"Команда: {parsed.command}")
|
||||
"""
|
||||
if not message.text:
|
||||
return None
|
||||
|
||||
return self.parse(message.text, parse_flags=parse_flags)
|
||||
|
||||
|
||||
# Глобальный парсер
|
||||
_default_parser: Optional[CommandParser] = None
|
||||
|
||||
|
||||
def get_parser() -> CommandParser:
|
||||
"""Получает глобальный парсер команд"""
|
||||
global _default_parser
|
||||
if _default_parser is None:
|
||||
_default_parser = CommandParser()
|
||||
return _default_parser
|
||||
|
||||
|
||||
# ================= УДОБНЫЕ ФУНКЦИИ =================
|
||||
|
||||
def is_command(text: Optional[str]) -> bool:
|
||||
"""
|
||||
Проверяет, является ли текст командой.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
bool: True если это команда
|
||||
|
||||
Example:
|
||||
>> is_command("/start")
|
||||
True
|
||||
>> is_command("hello")
|
||||
False
|
||||
"""
|
||||
return get_parser().is_command(text)
|
||||
|
||||
|
||||
def find_argument(text: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Извлекает аргументы команды.
|
||||
|
||||
Args:
|
||||
text: Текст команды
|
||||
|
||||
Returns:
|
||||
Optional[str]: Аргументы или None
|
||||
|
||||
Example:
|
||||
>> find_argument("/start referrer")
|
||||
'referrer'
|
||||
>> find_argument("/ban @user spam")
|
||||
'@user spam'
|
||||
"""
|
||||
return get_parser().find_argument(text)
|
||||
|
||||
|
||||
def get_command(text: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Извлекает название команды.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
|
||||
Returns:
|
||||
Optional[str]: Название команды или None
|
||||
|
||||
Example:
|
||||
>> get_command("/start@mybot")
|
||||
'start'
|
||||
>> get_command("!help")
|
||||
'help'
|
||||
"""
|
||||
return get_parser().get_command(text)
|
||||
|
||||
|
||||
def parse_arguments(args_text: Optional[str]) -> List[str]:
|
||||
"""
|
||||
Парсит аргументы команды.
|
||||
|
||||
Args:
|
||||
args_text: Строка аргументов
|
||||
|
||||
Returns:
|
||||
List[str]: Список аргументов
|
||||
|
||||
Example:
|
||||
>> parse_arguments('user 7d "ban reason"')
|
||||
['user', '7d', 'ban reason']
|
||||
"""
|
||||
return get_parser().parse_arguments(args_text)
|
||||
|
||||
|
||||
def parse_flags(args: List[str]) -> Tuple[List[str], Dict[str, Union[str, bool]]]:
|
||||
"""
|
||||
Парсит флаги из аргументов.
|
||||
|
||||
Args:
|
||||
args: Список аргументов
|
||||
|
||||
Returns:
|
||||
Tuple: (аргументы, флаги)
|
||||
|
||||
Example:
|
||||
>> args = ['user', '--reason', 'spam', '--silent']
|
||||
>> clean_args, flags = parse_flags(args)
|
||||
>> print(flags) # {'reason': 'spam', 'silent': True}
|
||||
"""
|
||||
return get_parser().parse_flags(args)
|
||||
|
||||
|
||||
def parse_command(text: str) -> Optional[ParsedCommand]:
|
||||
"""
|
||||
Полный парсинг команды.
|
||||
|
||||
Args:
|
||||
text: Текст команды
|
||||
|
||||
Returns:
|
||||
Optional[ParsedCommand]: Распарсенная команда
|
||||
|
||||
Example:
|
||||
>> parsed = parse_command('/ban @user --reason spam')
|
||||
>> print(parsed.command) # 'ban'
|
||||
>> print(parsed.args) # ['@user']
|
||||
>> print(parsed.flags) # {'reason': 'spam'}
|
||||
"""
|
||||
return get_parser().parse(text)
|
||||
|
||||
|
||||
# ================= ВАЛИДАЦИЯ КОМАНД =================
|
||||
|
||||
def validate_command(
|
||||
text: str,
|
||||
expected_command: str,
|
||||
min_args: int = 0,
|
||||
max_args: Optional[int] = None,
|
||||
required_flags: Optional[Set[str]] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Валидирует команду.
|
||||
|
||||
Args:
|
||||
text: Текст команды
|
||||
expected_command: Ожидаемая команда
|
||||
min_args: Минимальное количество аргументов
|
||||
max_args: Максимальное количество аргументов
|
||||
required_flags: Обязательные флаги
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (валидна, сообщение_об_ошибке)
|
||||
|
||||
Example:
|
||||
>> valid, error = validate_command(
|
||||
... '/ban user',
|
||||
... 'ban',
|
||||
... min_args=1,
|
||||
... max_args=2
|
||||
... )
|
||||
>> if not valid:
|
||||
... print(error)
|
||||
"""
|
||||
parsed = parse_command(text)
|
||||
|
||||
if not parsed:
|
||||
return False, "Невалидная команда"
|
||||
|
||||
# Проверка команды
|
||||
if parsed.command != expected_command:
|
||||
return False, f"Ожидалась команда '{expected_command}'"
|
||||
|
||||
# Проверка количества аргументов
|
||||
arg_count = len(parsed.args)
|
||||
|
||||
if arg_count < min_args:
|
||||
return False, f"Недостаточно аргументов (минимум {min_args})"
|
||||
|
||||
if max_args is not None and arg_count > max_args:
|
||||
return False, f"Слишком много аргументов (максимум {max_args})"
|
||||
|
||||
# Проверка обязательных флагов
|
||||
if required_flags:
|
||||
missing_flags = required_flags - set(parsed.flags.keys())
|
||||
if missing_flags:
|
||||
return False, f"Отсутствуют обязательные флаги: {', '.join(missing_flags)}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def get_command_usage(
|
||||
command: str,
|
||||
args: List[str],
|
||||
flags: Optional[Dict[str, str]] = None,
|
||||
description: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Формирует строку использования команды.
|
||||
|
||||
Args:
|
||||
command: Название команды
|
||||
args: Список аргументов
|
||||
flags: Словарь флагов с описанием
|
||||
description: Описание команды
|
||||
|
||||
Returns:
|
||||
str: Форматированная строка использования
|
||||
|
||||
Example:
|
||||
>> usage = get_command_usage(
|
||||
... 'ban',
|
||||
... ['<user>', '[duration]'],
|
||||
... {'reason': 'Причина бана', 'silent': 'Тихий бан'},
|
||||
... 'Банит пользователя'
|
||||
... )
|
||||
>> print(usage)
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Описание
|
||||
if description:
|
||||
lines.append(f"📝 {description}\n")
|
||||
|
||||
# Использование
|
||||
args_str = ' '.join(args)
|
||||
lines.append(f"<b>Использование:</b>")
|
||||
lines.append(f"<code>/{command} {args_str}</code>\n")
|
||||
|
||||
# Аргументы
|
||||
if args:
|
||||
lines.append("<b>Аргументы:</b>")
|
||||
for arg in args:
|
||||
# Определяем обязательность
|
||||
if arg.startswith('<') and arg.endswith('>'):
|
||||
lines.append(f"• {arg} - обязательный")
|
||||
elif arg.startswith('[') and arg.endswith(']'):
|
||||
lines.append(f"• {arg} - необязательный")
|
||||
lines.append("")
|
||||
|
||||
# Флаги
|
||||
if flags:
|
||||
lines.append("<b>Флаги:</b>")
|
||||
for flag, desc in flags.items():
|
||||
lines.append(f"• --{flag} - {desc}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ================= ИЗВЛЕЧЕНИЕ УПОМИНАНИЙ =================
|
||||
|
||||
def extract_mentions(text: str) -> List[str]:
|
||||
"""
|
||||
Извлекает все упоминания (@username) из текста.
|
||||
|
||||
Args:
|
||||
text: Текст для анализа
|
||||
|
||||
Returns:
|
||||
List[str]: Список username (без @)
|
||||
|
||||
Example:
|
||||
>> extract_mentions("Бан @user1 и @user2")
|
||||
['user1', 'user2']
|
||||
"""
|
||||
pattern = r'@(\w+)'
|
||||
return re.findall(pattern, text)
|
||||
|
||||
|
||||
def extract_user_ids(text: str) -> List[int]:
|
||||
"""
|
||||
Извлекает все ID пользователей из текста.
|
||||
|
||||
Args:
|
||||
text: Текст для анализа
|
||||
|
||||
Returns:
|
||||
List[int]: Список ID
|
||||
|
||||
Example:
|
||||
>> extract_user_ids("Бан id123456789 и id987654321")
|
||||
[123456789, 987654321]
|
||||
"""
|
||||
pattern = r'id(\d+)'
|
||||
matches = re.findall(pattern, text)
|
||||
return [int(m) for m in matches]
|
||||
|
||||
|
||||
def extract_hashtags(text: str) -> List[str]:
|
||||
"""
|
||||
Извлекает все хештеги из текста.
|
||||
|
||||
Args:
|
||||
text: Текст для анализа
|
||||
|
||||
Returns:
|
||||
List[str]: Список хештегов (без #)
|
||||
|
||||
Example:
|
||||
>> extract_hashtags("Пост #важное #новости")
|
||||
['важное', 'новости']
|
||||
"""
|
||||
pattern = r'#(\w+)'
|
||||
return re.findall(pattern, text)
|
||||
636
bot/utils/auto_delete.py
Normal file
636
bot/utils/auto_delete.py
Normal file
@@ -0,0 +1,636 @@
|
||||
"""
|
||||
Утилиты для автоматического удаления сообщений
|
||||
"""
|
||||
from typing import Optional, Callable, Awaitable, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from asyncio import sleep, create_task, Task, CancelledError
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
|
||||
from middleware.loggers import logger
|
||||
from .format_time import format_duration
|
||||
|
||||
__all__ = (
|
||||
'auto_delete_message',
|
||||
'schedule_delete',
|
||||
'cancel_delete',
|
||||
'delete_after',
|
||||
'auto_delete_manager',
|
||||
'AutoDeleteManager',
|
||||
'DeleteTask',
|
||||
'delete_both_after',
|
||||
'delete_messages_after',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteTask:
|
||||
"""
|
||||
Задача на удаление сообщения.
|
||||
|
||||
Attributes:
|
||||
chat_id: ID чата
|
||||
message_id: ID сообщения
|
||||
delete_at: Время удаления
|
||||
task: Asyncio task
|
||||
created_at: Время создания задачи
|
||||
reason: Причина удаления
|
||||
callback: Callback функция после удаления
|
||||
"""
|
||||
chat_id: int
|
||||
message_id: int
|
||||
delete_at: datetime
|
||||
task: Optional[Task] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
reason: Optional[str] = None
|
||||
callback: Optional[Callable[[], Awaitable[None]]] = None
|
||||
|
||||
@property
|
||||
def delay(self) -> int:
|
||||
"""Задержка до удаления в секундах"""
|
||||
delta = self.delete_at - datetime.now()
|
||||
return max(0, int(delta.total_seconds()))
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Истекло ли время удаления"""
|
||||
return datetime.now() >= self.delete_at
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"DeleteTask(chat={self.chat_id}, msg={self.message_id}, "
|
||||
f"delay={self.delay}s, reason={self.reason})"
|
||||
)
|
||||
|
||||
|
||||
class AutoDeleteManager:
|
||||
"""
|
||||
Менеджер автоматического удаления сообщений.
|
||||
|
||||
Возможности:
|
||||
- Планирование удаления с задержкой
|
||||
- Отмена запланированного удаления
|
||||
- Массовое удаление
|
||||
- Callback функции
|
||||
- История задач
|
||||
- Автоматическая очистка завершенных задач
|
||||
|
||||
Example:
|
||||
```python
|
||||
from utils.auto_delete import auto_delete_manager
|
||||
|
||||
# Планирование удаления
|
||||
await auto_delete_manager.schedule(
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
delay=60,
|
||||
reason="Временное сообщение"
|
||||
)
|
||||
|
||||
# Отмена удаления
|
||||
auto_delete_manager.cancel(message.chat.id, message.message_id)
|
||||
|
||||
# Получение статистики
|
||||
stats = auto_delete_manager.get_stats()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Активные задачи: {(chat_id, message_id): DeleteTask}
|
||||
self.tasks: Dict[tuple[int, int], DeleteTask] = {}
|
||||
|
||||
# Завершенные задачи (последние 100)
|
||||
self.completed: list[DeleteTask] = []
|
||||
self.max_completed = 100
|
||||
|
||||
# Статистика
|
||||
self.total_scheduled: int = 0
|
||||
self.total_deleted: int = 0
|
||||
self.total_failed: int = 0
|
||||
self.total_cancelled: int = 0
|
||||
|
||||
async def schedule(
|
||||
self,
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
delay: int,
|
||||
reason: Optional[str] = None,
|
||||
callback: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
log: bool = True
|
||||
) -> DeleteTask:
|
||||
"""
|
||||
Планирует удаление сообщения.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
message_id: ID сообщения
|
||||
delay: Задержка в секундах
|
||||
reason: Причина удаления
|
||||
callback: Callback функция после удаления
|
||||
log: Логировать планирование
|
||||
|
||||
Returns:
|
||||
DeleteTask: Созданная задача
|
||||
|
||||
Example:
|
||||
>> task = await auto_delete_manager.schedule(
|
||||
... bot=bot,
|
||||
... chat_id=message.chat.id,
|
||||
... message_id=message.message_id,
|
||||
... delay=60,
|
||||
... reason="Спам"
|
||||
... )
|
||||
"""
|
||||
# Отменяем предыдущую задачу если есть
|
||||
key = (chat_id, message_id)
|
||||
if key in self.tasks:
|
||||
self.cancel(chat_id, message_id)
|
||||
|
||||
# Создаем задачу
|
||||
delete_at = datetime.now() + timedelta(seconds=delay)
|
||||
task_data = DeleteTask(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
delete_at=delete_at,
|
||||
reason=reason,
|
||||
callback=callback
|
||||
)
|
||||
|
||||
# Создаем asyncio task
|
||||
task = create_task(self._delete_task(bot, task_data, log))
|
||||
task_data.task = task
|
||||
|
||||
# Сохраняем
|
||||
self.tasks[key] = task_data
|
||||
self.total_scheduled += 1
|
||||
|
||||
if log:
|
||||
delay_str = format_duration(delay)
|
||||
logger.info(
|
||||
f"Запланировано удаление сообщения через {delay_str}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
return task_data
|
||||
|
||||
async def _delete_task(
|
||||
self,
|
||||
bot: Bot,
|
||||
task_data: DeleteTask,
|
||||
log: bool
|
||||
) -> None:
|
||||
"""
|
||||
Внутренняя функция для выполнения задачи удаления.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
task_data: Данные задачи
|
||||
log: Логировать выполнение
|
||||
"""
|
||||
key = (task_data.chat_id, task_data.message_id)
|
||||
|
||||
try:
|
||||
# Ждем
|
||||
await sleep(task_data.delay)
|
||||
|
||||
# Удаляем сообщение
|
||||
await bot.delete_message(
|
||||
chat_id=task_data.chat_id,
|
||||
message_id=task_data.message_id
|
||||
)
|
||||
|
||||
self.total_deleted += 1
|
||||
|
||||
if log:
|
||||
reason_str = f" (причина: {task_data.reason})" if task_data.reason else ""
|
||||
logger.info(
|
||||
f"Сообщение удалено автоматически{reason_str}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
# Вызываем callback если есть
|
||||
if task_data.callback:
|
||||
try:
|
||||
await task_data.callback()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка в callback автоудаления: {e}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
except CancelledError:
|
||||
# Задача отменена
|
||||
self.total_cancelled += 1
|
||||
|
||||
if log:
|
||||
logger.debug(
|
||||
f"Удаление сообщения отменено",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
raise
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as e:
|
||||
# Ошибка удаления
|
||||
self.total_failed += 1
|
||||
|
||||
if log:
|
||||
logger.warning(
|
||||
f"Не удалось автоматически удалить сообщение: {e}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
finally:
|
||||
# Удаляем из активных задач
|
||||
if key in self.tasks:
|
||||
completed_task = self.tasks.pop(key)
|
||||
|
||||
# Сохраняем в завершенные
|
||||
self.completed.append(completed_task)
|
||||
if len(self.completed) > self.max_completed:
|
||||
self.completed.pop(0)
|
||||
|
||||
def cancel(
|
||||
self,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
log: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Отменяет запланированное удаление.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
message_id: ID сообщения
|
||||
log: Логировать отмену
|
||||
|
||||
Returns:
|
||||
bool: True если задача была отменена
|
||||
|
||||
Example:
|
||||
>> cancelled = auto_delete_manager.cancel(
|
||||
... chat_id=message.chat.id,
|
||||
... message_id=message.message_id
|
||||
... )
|
||||
"""
|
||||
key = (chat_id, message_id)
|
||||
|
||||
if key in self.tasks:
|
||||
task_data = self.tasks[key]
|
||||
|
||||
# Отменяем asyncio task
|
||||
if task_data.task and not task_data.task.done():
|
||||
task_data.task.cancel()
|
||||
|
||||
# Удаляем из активных
|
||||
self.tasks.pop(key)
|
||||
|
||||
if log:
|
||||
logger.debug(
|
||||
f"Автоудаление отменено для сообщения {message_id}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def cancel_all(self, chat_id: Optional[int] = None) -> int:
|
||||
"""
|
||||
Отменяет все запланированные удаления.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата (если None, отменяет для всех чатов)
|
||||
|
||||
Returns:
|
||||
int: Количество отмененных задач
|
||||
|
||||
Example:
|
||||
>> # Отменить для всех чатов
|
||||
>> count = auto_delete_manager.cancel_all()
|
||||
|
||||
>> # Отменить для конкретного чата
|
||||
>> count = auto_delete_manager.cancel_all(chat_id=message.chat.id)
|
||||
"""
|
||||
cancelled_count = 0
|
||||
|
||||
# Собираем ключи для отмены
|
||||
keys_to_cancel = []
|
||||
for key, task_data in self.tasks.items():
|
||||
if chat_id is None or task_data.chat_id == chat_id:
|
||||
keys_to_cancel.append(key)
|
||||
|
||||
# Отменяем
|
||||
for key in keys_to_cancel:
|
||||
if self.cancel(key[0], key[1], log=False):
|
||||
cancelled_count += 1
|
||||
|
||||
if cancelled_count > 0:
|
||||
logger.info(
|
||||
f"Отменено {cancelled_count} задач автоудаления",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
return cancelled_count
|
||||
|
||||
def get_task(
|
||||
self,
|
||||
chat_id: int,
|
||||
message_id: int
|
||||
) -> Optional[DeleteTask]:
|
||||
"""
|
||||
Получает задачу по ID чата и сообщения.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
message_id: ID сообщения
|
||||
|
||||
Returns:
|
||||
Optional[DeleteTask]: Задача или None
|
||||
"""
|
||||
key = (chat_id, message_id)
|
||||
return self.tasks.get(key)
|
||||
|
||||
def get_chat_tasks(self, chat_id: int) -> list[DeleteTask]:
|
||||
"""
|
||||
Получает все задачи для чата.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
list[DeleteTask]: Список задач
|
||||
"""
|
||||
return [
|
||||
task for task in self.tasks.values()
|
||||
if task.chat_id == chat_id
|
||||
]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает статистику менеджера.
|
||||
|
||||
Returns:
|
||||
Dict: Словарь со статистикой
|
||||
|
||||
Example:
|
||||
>> stats = auto_delete_manager.get_stats()
|
||||
>> print(f"Активных задач: {stats['active_tasks']}")
|
||||
"""
|
||||
return {
|
||||
'active_tasks': len(self.tasks),
|
||||
'completed_tasks': len(self.completed),
|
||||
'total_scheduled': self.total_scheduled,
|
||||
'total_deleted': self.total_deleted,
|
||||
'total_failed': self.total_failed,
|
||||
'total_cancelled': self.total_cancelled,
|
||||
'success_rate': (
|
||||
f"{(self.total_deleted / self.total_scheduled * 100):.1f}%"
|
||||
if self.total_scheduled > 0 else "0%"
|
||||
)
|
||||
}
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""
|
||||
Удаляет истекшие задачи (которые должны были выполниться, но не выполнились).
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных задач
|
||||
"""
|
||||
expired_keys = [
|
||||
key for key, task in self.tasks.items()
|
||||
if task.is_expired and (not task.task or task.task.done())
|
||||
]
|
||||
|
||||
for key in expired_keys:
|
||||
self.tasks.pop(key)
|
||||
|
||||
return len(expired_keys)
|
||||
|
||||
|
||||
# Глобальный менеджер
|
||||
auto_delete_manager = AutoDeleteManager()
|
||||
|
||||
|
||||
# ================= УДОБНЫЕ ФУНКЦИИ =================
|
||||
|
||||
async def auto_delete_message(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
delay: int = 604800,
|
||||
reason: Optional[str] = None
|
||||
) -> DeleteTask:
|
||||
"""
|
||||
Автоматически удаляет сообщение через указанное время.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
message_id: ID сообщения
|
||||
delay: Задержка в секундах (по умолчанию 7 дней)
|
||||
reason: Причина удаления
|
||||
|
||||
Returns:
|
||||
DeleteTask: Созданная задача
|
||||
|
||||
Example:
|
||||
>> # Удалить через 1 минуту
|
||||
>> await auto_delete_message(bot, chat_id, message_id, delay=60)
|
||||
|
||||
>> # Удалить через 7 дней (по умолчанию)
|
||||
>> await auto_delete_message(bot, chat_id, message_id)
|
||||
"""
|
||||
return await auto_delete_manager.schedule(
|
||||
bot=bot,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
delay=delay,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
|
||||
async def schedule_delete(
|
||||
message: Message,
|
||||
delay: int,
|
||||
reason: Optional[str] = None
|
||||
) -> DeleteTask:
|
||||
"""
|
||||
Планирует удаление сообщения (упрощенная версия).
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
delay: Задержка в секундах
|
||||
reason: Причина удаления
|
||||
|
||||
Returns:
|
||||
DeleteTask: Созданная задача
|
||||
|
||||
Example:
|
||||
>> # Планируем удаление через 30 секунд
|
||||
>> await schedule_delete(message, delay=30, reason="Временное")
|
||||
"""
|
||||
return await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
delay=delay,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
|
||||
def cancel_delete(message: Message) -> bool:
|
||||
"""
|
||||
Отменяет запланированное удаление сообщения.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
bool: True если удаление было отменено
|
||||
|
||||
Example:
|
||||
>> if cancel_delete(message):
|
||||
... await message.answer("Удаление отменено")
|
||||
"""
|
||||
return auto_delete_manager.cancel(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id
|
||||
)
|
||||
|
||||
|
||||
async def delete_after(
|
||||
message: Message,
|
||||
text: str,
|
||||
delay: int = 10,
|
||||
**kwargs
|
||||
) -> Message:
|
||||
"""
|
||||
Отправляет сообщение и автоматически удаляет его через указанное время.
|
||||
|
||||
Args:
|
||||
message: Исходное сообщение
|
||||
text: Текст нового сообщения
|
||||
delay: Задержка до удаления в секундах
|
||||
**kwargs: Дополнительные параметры для message.answer()
|
||||
|
||||
Returns:
|
||||
Message: Отправленное сообщение
|
||||
|
||||
Example:
|
||||
>> # Отправить и удалить через 10 секунд
|
||||
>> await delete_after(message, "Это временное сообщение")
|
||||
|
||||
>> # Отправить и удалить через 5 секунд
|
||||
>> await delete_after(
|
||||
... message,
|
||||
... "⚠️ Ошибка!",
|
||||
... delay=5,
|
||||
... parse_mode="HTML"
|
||||
... )
|
||||
"""
|
||||
sent_message = await message.answer(text, **kwargs)
|
||||
|
||||
await auto_delete_manager.schedule(
|
||||
bot=message.bot,
|
||||
chat_id=sent_message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
delay=delay,
|
||||
reason="delete_after"
|
||||
)
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
async def delete_both_after(
|
||||
original: Message,
|
||||
reply_text: str,
|
||||
delay: int = 10,
|
||||
**kwargs
|
||||
) -> Message:
|
||||
"""
|
||||
Отправляет ответ и удаляет оба сообщения через указанное время.
|
||||
|
||||
Args:
|
||||
original: Исходное сообщение
|
||||
reply_text: Текст ответа
|
||||
delay: Задержка до удаления
|
||||
**kwargs: Дополнительные параметры
|
||||
|
||||
Returns:
|
||||
Message: Отправленное сообщение
|
||||
|
||||
Example:
|
||||
>> # Удалить и команду, и ответ через 5 секунд
|
||||
>> await delete_both_after(
|
||||
... message,
|
||||
... "✅ Команда выполнена",
|
||||
... delay=5
|
||||
... )
|
||||
"""
|
||||
# Отправляем ответ
|
||||
sent = await delete_after(original, reply_text, delay, **kwargs)
|
||||
|
||||
# Планируем удаление оригинала
|
||||
await auto_delete_manager.schedule(
|
||||
bot=original.bot,
|
||||
chat_id=original.chat.id,
|
||||
message_id=original.message_id,
|
||||
delay=delay,
|
||||
reason="delete_both"
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
async def delete_messages_after(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
message_ids: list[int],
|
||||
delay: int
|
||||
) -> int:
|
||||
"""
|
||||
Планирует удаление нескольких сообщений.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
message_ids: Список ID сообщений
|
||||
delay: Задержка до удаления
|
||||
|
||||
Returns:
|
||||
int: Количество запланированных удалений
|
||||
|
||||
Example:
|
||||
>> # Удалить все сообщения через 1 час
|
||||
>> count = await delete_messages_after(
|
||||
... bot,
|
||||
... chat_id,
|
||||
... [123, 124, 125, 126],
|
||||
... delay=3600
|
||||
... )
|
||||
"""
|
||||
count = 0
|
||||
|
||||
for message_id in message_ids:
|
||||
await auto_delete_manager.schedule(
|
||||
bot=bot,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
delay=delay,
|
||||
reason="mass_delete",
|
||||
log=False
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
f"Запланировано удаление {count} сообщений через {format_duration(delay)}",
|
||||
log_type='AUTO_DELETE'
|
||||
)
|
||||
|
||||
return count
|
||||
812
bot/utils/decorators.py
Normal file
812
bot/utils/decorators.py
Normal file
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
Декораторы для обработчиков бота
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Callable, Optional, Union
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.enums import ChatType, ChatMemberStatus
|
||||
|
||||
from middleware.loggers import logger
|
||||
from .format_time import format_duration
|
||||
|
||||
__all__ = (
|
||||
'admin_only',
|
||||
'owner_only',
|
||||
'private_only',
|
||||
'group_only',
|
||||
'rate_limit',
|
||||
'cooldown',
|
||||
'log_action',
|
||||
'catch_errors',
|
||||
'typing_action',
|
||||
'delete_command',
|
||||
'answer_on_error',
|
||||
'permission_required',
|
||||
'throttle',
|
||||
'admin_action'
|
||||
)
|
||||
|
||||
|
||||
# ================= ХРАНИЛИЩА ДЛЯ RATE LIMIT =================
|
||||
|
||||
class RateLimitStorage:
|
||||
"""Хранилище для rate limiting"""
|
||||
|
||||
def __init__(self):
|
||||
# {user_id: {action: datetime}}
|
||||
self._storage: dict[int, dict[str, datetime]] = defaultdict(dict)
|
||||
# {user_id: {action: count}}
|
||||
self._counters: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
|
||||
def check(
|
||||
self,
|
||||
user_id: int,
|
||||
action: str,
|
||||
limit: int,
|
||||
period: int
|
||||
) -> tuple[bool, Optional[int]]:
|
||||
"""
|
||||
Проверяет лимит.
|
||||
|
||||
Returns:
|
||||
tuple[bool, Optional[int]]: (можно ли выполнить, секунд до сброса)
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
if action not in self._storage[user_id]:
|
||||
# Первое использование
|
||||
self._storage[user_id][action] = now
|
||||
self._counters[user_id][action] = 1
|
||||
return True, None
|
||||
|
||||
last_use = self._storage[user_id][action]
|
||||
time_passed = (now - last_use).total_seconds()
|
||||
|
||||
# Если прошел период - сбрасываем
|
||||
if time_passed >= period:
|
||||
self._storage[user_id][action] = now
|
||||
self._counters[user_id][action] = 1
|
||||
return True, None
|
||||
|
||||
# Проверяем счетчик
|
||||
count = self._counters[user_id][action]
|
||||
|
||||
if count >= limit:
|
||||
# Превышен лимит
|
||||
retry_after = int(period - time_passed)
|
||||
return False, retry_after
|
||||
|
||||
# Увеличиваем счетчик
|
||||
self._counters[user_id][action] += 1
|
||||
return True, None
|
||||
|
||||
def reset(self, user_id: int, action: Optional[str] = None):
|
||||
"""Сбрасывает лимит для пользователя"""
|
||||
if action:
|
||||
if user_id in self._storage:
|
||||
self._storage[user_id].pop(action, None)
|
||||
self._counters[user_id].pop(action, None)
|
||||
else:
|
||||
self._storage.pop(user_id, None)
|
||||
self._counters.pop(user_id, None)
|
||||
|
||||
def cleanup(self, max_age: int = 3600):
|
||||
"""Очищает старые записи"""
|
||||
now = datetime.now()
|
||||
expired_users = []
|
||||
|
||||
for user_id, actions in self._storage.items():
|
||||
expired_actions = [
|
||||
action for action, dt in actions.items()
|
||||
if (now - dt).total_seconds() > max_age
|
||||
]
|
||||
|
||||
for action in expired_actions:
|
||||
actions.pop(action, None)
|
||||
self._counters[user_id].pop(action, None)
|
||||
|
||||
if not actions:
|
||||
expired_users.append(user_id)
|
||||
|
||||
for user_id in expired_users:
|
||||
self._storage.pop(user_id, None)
|
||||
self._counters.pop(user_id, None)
|
||||
|
||||
|
||||
# Глобальное хранилище
|
||||
_rate_limit_storage = RateLimitStorage()
|
||||
_cooldown_storage = RateLimitStorage()
|
||||
|
||||
|
||||
# ================= ПРОВЕРКА ПРАВ =================
|
||||
|
||||
async def _check_admin_rights(
|
||||
message: Message,
|
||||
user_id: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет, является ли пользователь администратором.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
user_id: ID пользователя (если None, проверяется отправитель)
|
||||
|
||||
Returns:
|
||||
bool: True если администратор
|
||||
"""
|
||||
# В личных сообщениях все пользователи "администраторы"
|
||||
if message.chat.type == ChatType.PRIVATE:
|
||||
return True
|
||||
|
||||
check_user_id = user_id or message.from_user.id
|
||||
|
||||
try:
|
||||
member = await message.bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=check_user_id
|
||||
)
|
||||
|
||||
return member.status in {
|
||||
ChatMemberStatus.CREATOR,
|
||||
ChatMemberStatus.ADMINISTRATOR
|
||||
}
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
async def _check_owner_rights(message: Message) -> bool:
|
||||
"""Проверяет, является ли пользователь владельцем чата"""
|
||||
if message.chat.type == ChatType.PRIVATE:
|
||||
return True
|
||||
|
||||
try:
|
||||
member = await message.bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
return member.status == ChatMemberStatus.CREATOR
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
async def _check_bot_admin_rights(message: Message) -> bool:
|
||||
"""Проверяет, является ли бот администратором"""
|
||||
if message.chat.type == ChatType.PRIVATE:
|
||||
return True
|
||||
|
||||
try:
|
||||
bot_member = await message.bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.bot.id
|
||||
)
|
||||
|
||||
return bot_member.status in {
|
||||
ChatMemberStatus.ADMINISTRATOR
|
||||
}
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return False
|
||||
|
||||
|
||||
# ================= ДЕКОРАТОРЫ ДЛЯ ПРАВ =================
|
||||
|
||||
def admin_only(
|
||||
reply_text: str = "❌ Эта команда доступна только администраторам",
|
||||
check_bot: bool = False
|
||||
):
|
||||
"""
|
||||
Декоратор: выполнение только для администраторов.
|
||||
|
||||
Args:
|
||||
reply_text: Текст ответа если не админ
|
||||
check_bot: Также проверять права бота
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("ban"))
|
||||
@admin_only()
|
||||
async def ban_handler(message: Message):
|
||||
await message.answer("Бан пользователя...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
# Получаем message
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
# Проверяем права пользователя
|
||||
if not await _check_admin_rights(message):
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(reply_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(reply_text)
|
||||
|
||||
logger.warning(
|
||||
f"Попытка использования admin команды от @{message.from_user.id}",
|
||||
log_type='SECURITY'
|
||||
)
|
||||
return None
|
||||
|
||||
# Проверяем права бота если нужно
|
||||
if check_bot and not await _check_bot_admin_rights(message):
|
||||
error_text = "❌ Бот не является администратором чата"
|
||||
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(error_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def owner_only(reply_text: str = "❌ Эта команда доступна только владельцу чата"):
|
||||
"""
|
||||
Декоратор: выполнение только для владельца чата.
|
||||
|
||||
Args:
|
||||
reply_text: Текст ответа если не владелец
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("destroy"))
|
||||
@owner_only()
|
||||
async def destroy_handler(message: Message):
|
||||
await message.answer("Удаление чата...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if not await _check_owner_rights(message):
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(reply_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(reply_text)
|
||||
|
||||
logger.warning(
|
||||
f"Попытка использования owner команды от @{message.from_user.id}",
|
||||
log_type='SECURITY'
|
||||
)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def permission_required(*permissions: str):
|
||||
"""
|
||||
Декоратор: проверка конкретных прав администратора.
|
||||
|
||||
Args:
|
||||
permissions: Список прав (can_delete_messages, can_restrict_members, и т.д.)
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("pin"))
|
||||
@permission_required("can_pin_messages")
|
||||
async def pin_handler(message: Message):
|
||||
await message.reply_to_message.pin()
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
# В личных сообщениях пропускаем проверку
|
||||
if message.chat.type == ChatType.PRIVATE:
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
try:
|
||||
member = await message.bot.get_chat_member(
|
||||
chat_id=message.chat.id,
|
||||
user_id=message.from_user.id
|
||||
)
|
||||
|
||||
# Владелец имеет все права
|
||||
if member.status == ChatMemberStatus.CREATOR:
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
# Проверяем права
|
||||
if member.status == ChatMemberStatus.ADMINISTRATOR:
|
||||
missing_permissions = []
|
||||
|
||||
for perm in permissions:
|
||||
if not getattr(member, perm, False):
|
||||
missing_permissions.append(perm)
|
||||
|
||||
if missing_permissions:
|
||||
error_text = (
|
||||
f"❌ Недостаточно прав\n"
|
||||
f"Требуются: {', '.join(missing_permissions)}"
|
||||
)
|
||||
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(error_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
# Не администратор
|
||||
error_text = "❌ Эта команда доступна только администраторам"
|
||||
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(error_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text)
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
pass
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ================= ДЕКОРАТОРЫ ДЛЯ ТИПОВ ЧАТОВ =================
|
||||
|
||||
def private_only(reply_text: str = "❌ Эта команда работает только в личных сообщениях"):
|
||||
"""
|
||||
Декоратор: выполнение только в личных сообщениях.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("start"))
|
||||
@private_only()
|
||||
async def start_handler(message: Message):
|
||||
await message.answer("Приветствие...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if message.chat.type != ChatType.PRIVATE:
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(reply_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(reply_text)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def group_only(reply_text: str = "❌ Эта команда работает только в группах"):
|
||||
"""
|
||||
Декоратор: выполнение только в группах.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("ban"))
|
||||
@group_only()
|
||||
async def ban_handler(message: Message):
|
||||
await message.answer("Бан пользователя...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if message.chat.type not in {ChatType.GROUP, ChatType.SUPERGROUP}:
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(reply_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(reply_text)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ================= RATE LIMITING =================
|
||||
|
||||
def rate_limit(limit: int = 1, period: int = 60, action: Optional[str] = None):
|
||||
"""
|
||||
Декоратор: ограничение частоты вызовов.
|
||||
|
||||
Args:
|
||||
limit: Количество вызовов
|
||||
period: Период в секундах
|
||||
action: Название действия (по умолчанию имя функции)
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("search"))
|
||||
@rate_limit(limit=3, period=60) # 3 раза в минуту
|
||||
async def search_handler(message: Message):
|
||||
await message.answer("Поиск...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
action_name = action or func.__name__
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
user_id = message.from_user.id
|
||||
|
||||
# Проверяем лимит
|
||||
allowed, retry_after = _rate_limit_storage.check(
|
||||
user_id, action_name, limit, period
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
retry_time = format_duration(retry_after)
|
||||
error_text = f"⏳ Слишком часто! Повторите через {retry_time}"
|
||||
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(error_text, show_alert=True)
|
||||
else:
|
||||
await message.answer(error_text)
|
||||
|
||||
logger.debug(
|
||||
f"Rate limit для пользователя {user_id}: {action_name}",
|
||||
log_type='RATE_LIMIT'
|
||||
)
|
||||
return None
|
||||
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cooldown(seconds: int, action: Optional[str] = None):
|
||||
"""
|
||||
Декоратор: кулдаун между вызовами (1 раз в N секунд).
|
||||
|
||||
Args:
|
||||
seconds: Кулдаун в секундах
|
||||
action: Название действия
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("daily"))
|
||||
@cooldown(seconds=86400) # Раз в день
|
||||
async def daily_handler(message: Message):
|
||||
await message.answer("Ежедневная награда!")
|
||||
```
|
||||
"""
|
||||
return rate_limit(limit=1, period=seconds, action=action)
|
||||
|
||||
|
||||
def throttle(rate: float = 1.0):
|
||||
"""
|
||||
Декоратор: throttling (antiflood).
|
||||
|
||||
Args:
|
||||
rate: Минимальный интервал в секундах между вызовами
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message()
|
||||
@throttle(rate=0.5) # Не чаще 2 раз в секунду
|
||||
async def echo_handler(message: Message):
|
||||
await message.answer(message.text)
|
||||
```
|
||||
"""
|
||||
return cooldown(seconds=int(rate), action='throttle')
|
||||
|
||||
|
||||
# ================= ЛОГИРОВАНИЕ =================
|
||||
|
||||
def log_action(
|
||||
action_name: Optional[str] = None,
|
||||
log_args: bool = False
|
||||
):
|
||||
"""
|
||||
Декоратор: логирование действий.
|
||||
|
||||
Args:
|
||||
action_name: Название действия (по умолчанию имя функции)
|
||||
log_args: Логировать аргументы
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("ban"))
|
||||
@log_action("BAN_USER", log_args=True)
|
||||
async def ban_handler(message: Message):
|
||||
await message.answer("Бан...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
name = action_name or func.__name__
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
user_id = message.from_user.id
|
||||
username = message.from_user.username or f"id{user_id}"
|
||||
|
||||
# Логируем начало
|
||||
log_msg = f"Действие '{name}' от @{username}"
|
||||
|
||||
if log_args and message.text:
|
||||
log_msg += f" | Аргументы: {message.text}"
|
||||
|
||||
logger.info(log_msg, log_type='ACTION')
|
||||
|
||||
try:
|
||||
result = await func(update, *args, **kwargs)
|
||||
logger.info(f"Действие '{name}' выполнено успешно", log_type='ACTION')
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в действии '{name}': {e}", log_type='ACTION')
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ================= ОБРАБОТКА ОШИБОК =================
|
||||
|
||||
def catch_errors(
|
||||
notify_user: bool = True,
|
||||
error_message: str = "❌ Произошла ошибка при выполнении команды"
|
||||
):
|
||||
"""
|
||||
Декоратор: перехват ошибок.
|
||||
|
||||
Args:
|
||||
notify_user: Уведомлять пользователя об ошибке
|
||||
error_message: Текст уведомления
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("risky"))
|
||||
@catch_errors(notify_user=True)
|
||||
async def risky_handler(message: Message):
|
||||
# Код который может вызвать ошибку
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
try:
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка в {func.__name__}: {e}",
|
||||
log_type='ERROR'
|
||||
)
|
||||
|
||||
if notify_user:
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if message:
|
||||
try:
|
||||
if isinstance(update, CallbackQuery):
|
||||
await update.answer(error_message, show_alert=True)
|
||||
else:
|
||||
await message.answer(error_message)
|
||||
except:
|
||||
pass
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def answer_on_error(error_message: str = "❌ Ошибка"):
|
||||
"""
|
||||
Декоратор: ответ пользователю при ошибке.
|
||||
|
||||
Alias для catch_errors с уведомлением.
|
||||
"""
|
||||
return catch_errors(notify_user=True, error_message=error_message)
|
||||
|
||||
|
||||
# ================= ДЕЙСТВИЯ =================
|
||||
|
||||
def typing_action():
|
||||
"""
|
||||
Декоратор: показывает "печатает..." во время выполнения.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("search"))
|
||||
@typing_action()
|
||||
async def search_handler(message: Message):
|
||||
# Долгий поиск...
|
||||
await asyncio.sleep(3)
|
||||
await message.answer("Результаты поиска")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(update: Union[Message, CallbackQuery], *args, **kwargs):
|
||||
message = update if isinstance(update, Message) else update.message
|
||||
|
||||
if not message:
|
||||
return await func(update, *args, **kwargs)
|
||||
|
||||
# Отправляем действие "печатает"
|
||||
async def send_typing():
|
||||
try:
|
||||
while True:
|
||||
await message.bot.send_chat_action(
|
||||
chat_id=message.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
await asyncio.sleep(4) # Обновляем каждые 4 секунды
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Создаем задачу
|
||||
typing_task = asyncio.create_task(send_typing())
|
||||
|
||||
try:
|
||||
result = await func(update, *args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def delete_command(delay: Optional[int] = None):
|
||||
"""
|
||||
Декоратор: удаляет команду после выполнения.
|
||||
|
||||
Args:
|
||||
delay: Задержка перед удалением (секунды)
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("clean"))
|
||||
@delete_command(delay=0)
|
||||
async def clean_handler(message: Message):
|
||||
await message.answer("Очистка...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(message: Message, *args, **kwargs):
|
||||
if not isinstance(message, Message):
|
||||
return await func(message, *args, **kwargs)
|
||||
|
||||
# Выполняем функцию
|
||||
result = await func(message, *args, **kwargs)
|
||||
|
||||
# Удаляем команду
|
||||
try:
|
||||
if delay:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
await message.delete()
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ================= КОМБИНИРОВАННЫЕ ДЕКОРАТОРЫ =================
|
||||
|
||||
def admin_action(
|
||||
log: bool = True,
|
||||
check_bot: bool = True,
|
||||
delete_cmd: bool = False
|
||||
):
|
||||
"""
|
||||
Комбинированный декоратор для admin команд.
|
||||
|
||||
Args:
|
||||
log: Логировать действие
|
||||
check_bot: Проверять права бота
|
||||
delete_cmd: Удалять команду
|
||||
|
||||
Example:
|
||||
```python
|
||||
@router.message(Command("ban"))
|
||||
@admin_action(log=True, check_bot=True)
|
||||
async def ban_handler(message: Message):
|
||||
await message.answer("Бан...")
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
# Применяем декораторы
|
||||
decorated = func
|
||||
|
||||
if log:
|
||||
decorated = log_action(f"ADMIN_{func.__name__.upper()}")(decorated)
|
||||
|
||||
decorated = admin_only(check_bot=check_bot)(decorated)
|
||||
|
||||
if delete_cmd:
|
||||
decorated = delete_command()(decorated)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ================= ОЧИСТКА ХРАНИЛИЩ =================
|
||||
|
||||
def cleanup_storage(max_age: int = 3600):
|
||||
"""
|
||||
Очищает хранилища rate limit от старых записей.
|
||||
|
||||
Args:
|
||||
max_age: Максимальный возраст записи в секундах
|
||||
"""
|
||||
_rate_limit_storage.cleanup(max_age)
|
||||
_cooldown_storage.cleanup(max_age)
|
||||
523
bot/utils/format_time.py
Normal file
523
bot/utils/format_time.py
Normal file
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Утилиты для форматирования времени и дат
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
__all__ = (
|
||||
'format_duration',
|
||||
'format_retry_time',
|
||||
'format_timestamp',
|
||||
'format_relative_time',
|
||||
'parse_duration',
|
||||
'TimeFormat',
|
||||
'get_plural_form',
|
||||
'seconds_to_human',
|
||||
'time_until',
|
||||
'time_since',
|
||||
'format_date_range',
|
||||
'is_today',
|
||||
'is_yesterday',
|
||||
'is_tomorrow',
|
||||
'smart_date'
|
||||
)
|
||||
|
||||
|
||||
class TimeFormat(str, Enum):
|
||||
"""Форматы времени"""
|
||||
FULL = 'full' # 1 час 30 минут 45 секунд
|
||||
SHORT = 'short' # 1ч 30м 45с
|
||||
COMPACT = 'compact' # 1:30:45
|
||||
MINIMAL = 'minimal' # 1ч 30м (без секунд если есть часы/минуты)
|
||||
|
||||
|
||||
def get_plural_form(number: int, forms: tuple[str, str, str]) -> str:
|
||||
"""
|
||||
Возвращает правильную форму множественного числа для русского языка.
|
||||
|
||||
Args:
|
||||
number: Число
|
||||
forms: Кортеж форм (1 секунда, 2 секунды, 5 секунд)
|
||||
|
||||
Returns:
|
||||
str: Правильная форма
|
||||
|
||||
Example:
|
||||
>> get_plural_form(1, ('секунда', 'секунды', 'секунд'))
|
||||
'секунда'
|
||||
>> get_plural_form(2, ('секунда', 'секунды', 'секунд'))
|
||||
'секунды'
|
||||
>> get_plural_form(5, ('секунда', 'секунды', 'секунд'))
|
||||
'секунд'
|
||||
"""
|
||||
n = abs(number)
|
||||
n %= 100
|
||||
|
||||
if 5 <= n <= 20:
|
||||
return forms[2]
|
||||
|
||||
n %= 10
|
||||
|
||||
if n == 1:
|
||||
return forms[0]
|
||||
elif 2 <= n <= 4:
|
||||
return forms[1]
|
||||
else:
|
||||
return forms[2]
|
||||
|
||||
|
||||
def format_duration(
|
||||
seconds: int,
|
||||
format_type: TimeFormat = TimeFormat.FULL,
|
||||
include_seconds: bool = True,
|
||||
max_units: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
Форматирует длительность в читаемый вид.
|
||||
|
||||
Args:
|
||||
seconds: Длительность в секундах
|
||||
format_type: Тип форматирования
|
||||
include_seconds: Включать секунды в вывод
|
||||
max_units: Максимальное количество единиц времени (например, только часы и минуты)
|
||||
|
||||
Returns:
|
||||
str: Отформатированная строка
|
||||
|
||||
Example:
|
||||
>> format_duration(3665)
|
||||
'1 час 1 минута 5 секунд'
|
||||
|
||||
>> format_duration(3665, TimeFormat.SHORT)
|
||||
'1ч 1м 5с'
|
||||
|
||||
>> format_duration(3665, TimeFormat.COMPACT)
|
||||
'1:01:05'
|
||||
|
||||
>> format_duration(3665, max_units=2)
|
||||
'1 час 1 минута'
|
||||
"""
|
||||
if seconds == 0:
|
||||
if format_type == TimeFormat.FULL:
|
||||
return "0 секунд"
|
||||
elif format_type == TimeFormat.SHORT:
|
||||
return "0с"
|
||||
elif format_type == TimeFormat.COMPACT:
|
||||
return "0:00"
|
||||
else:
|
||||
return "0с"
|
||||
|
||||
# Разбиваем на единицы
|
||||
weeks, remainder = divmod(seconds, 604800) # 7 * 24 * 60 * 60
|
||||
days, remainder = divmod(remainder, 86400) # 24 * 60 * 60
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, secs = divmod(remainder, 60)
|
||||
|
||||
# Компактный формат
|
||||
if format_type == TimeFormat.COMPACT:
|
||||
if weeks > 0:
|
||||
return f"{weeks * 7 + days}д {hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
elif days > 0:
|
||||
return f"{days}д {hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
elif hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
else:
|
||||
return f"0:{secs:02d}"
|
||||
|
||||
# Собираем части
|
||||
parts = []
|
||||
units_count = 0
|
||||
|
||||
# Недели
|
||||
if weeks > 0:
|
||||
if format_type == TimeFormat.SHORT:
|
||||
parts.append(f"{weeks}нед")
|
||||
else:
|
||||
week_form = get_plural_form(weeks, ('неделя', 'недели', 'недель'))
|
||||
parts.append(f"{weeks} {week_form}")
|
||||
units_count += 1
|
||||
if max_units and units_count >= max_units:
|
||||
return ' '.join(parts)
|
||||
|
||||
# Дни
|
||||
if days > 0:
|
||||
if format_type == TimeFormat.SHORT:
|
||||
parts.append(f"{days}д")
|
||||
else:
|
||||
day_form = get_plural_form(days, ('день', 'дня', 'дней'))
|
||||
parts.append(f"{days} {day_form}")
|
||||
units_count += 1
|
||||
if max_units and units_count >= max_units:
|
||||
return ' '.join(parts)
|
||||
|
||||
# Часы
|
||||
if hours > 0:
|
||||
if format_type == TimeFormat.SHORT:
|
||||
parts.append(f"{hours}ч")
|
||||
else:
|
||||
hour_form = get_plural_form(hours, ('час', 'часа', 'часов'))
|
||||
parts.append(f"{hours} {hour_form}")
|
||||
units_count += 1
|
||||
if max_units and units_count >= max_units:
|
||||
return ' '.join(parts)
|
||||
|
||||
# Минуты
|
||||
if minutes > 0:
|
||||
if format_type == TimeFormat.SHORT:
|
||||
parts.append(f"{minutes}м")
|
||||
else:
|
||||
minute_form = get_plural_form(minutes, ('минута', 'минуты', 'минут'))
|
||||
parts.append(f"{minutes} {minute_form}")
|
||||
units_count += 1
|
||||
if max_units and units_count >= max_units:
|
||||
return ' '.join(parts)
|
||||
|
||||
# Секунды
|
||||
if secs > 0 and include_seconds:
|
||||
# Минимальный формат: не показываем секунды если есть часы или дни
|
||||
if format_type == TimeFormat.MINIMAL and (hours > 0 or days > 0 or weeks > 0):
|
||||
pass
|
||||
else:
|
||||
if format_type == TimeFormat.SHORT:
|
||||
parts.append(f"{secs}с")
|
||||
else:
|
||||
second_form = get_plural_form(secs, ('секунда', 'секунды', 'секунд'))
|
||||
parts.append(f"{secs} {second_form}")
|
||||
|
||||
return ' '.join(parts) if parts else "0 секунд"
|
||||
|
||||
|
||||
def format_retry_time(retry_after: int, format_type: TimeFormat = TimeFormat.FULL) -> str:
|
||||
"""
|
||||
Форматирует время повторной попытки.
|
||||
|
||||
Args:
|
||||
retry_after: Время в секундах до следующей попытки
|
||||
format_type: Тип форматирования
|
||||
|
||||
Returns:
|
||||
str: Отформатированная строка
|
||||
|
||||
Example:
|
||||
>> format_retry_time(3665)
|
||||
'1 час 1 минута 5 секунд'
|
||||
|
||||
>> format_retry_time(3665, TimeFormat.SHORT)
|
||||
'1ч 1м 5с'
|
||||
"""
|
||||
return format_duration(retry_after, format_type=format_type)
|
||||
|
||||
|
||||
def format_timestamp(
|
||||
timestamp: Union[int, float, datetime],
|
||||
format_string: str = "%d.%m.%Y %H:%M:%S",
|
||||
timezone_offset: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
Форматирует timestamp в читаемую дату.
|
||||
|
||||
Args:
|
||||
timestamp: Unix timestamp или datetime объект
|
||||
format_string: Формат вывода
|
||||
timezone_offset: Смещение часового пояса в часах
|
||||
|
||||
Returns:
|
||||
str: Отформатированная дата
|
||||
|
||||
Example:
|
||||
>> format_timestamp(1640000000)
|
||||
'20.12.2021 13:33:20'
|
||||
|
||||
>> format_timestamp(datetime.now(), "%d %B %Y")
|
||||
'17 февраля 2026'
|
||||
"""
|
||||
if isinstance(timestamp, (int, float)):
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
else:
|
||||
dt = timestamp
|
||||
|
||||
# Применяем смещение часового пояса
|
||||
if timezone_offset is not None:
|
||||
dt = dt + timedelta(hours=timezone_offset)
|
||||
|
||||
return dt.strftime(format_string)
|
||||
|
||||
|
||||
def format_relative_time(
|
||||
timestamp: Union[int, float, datetime],
|
||||
now: Optional[datetime] = None,
|
||||
detailed: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Форматирует время относительно текущего момента.
|
||||
|
||||
Args:
|
||||
timestamp: Unix timestamp или datetime объект
|
||||
now: Текущее время (по умолчанию datetime.now())
|
||||
detailed: Детальный формат (например "2 часа 30 минут назад" вместо "2 часа назад")
|
||||
|
||||
Returns:
|
||||
str: Относительное время
|
||||
|
||||
Example:
|
||||
>> format_relative_time(time.time() - 3600)
|
||||
'1 час назад'
|
||||
|
||||
>> format_relative_time(time.time() + 7200)
|
||||
'через 2 часа'
|
||||
|
||||
>> format_relative_time(time.time() - 90, detailed=True)
|
||||
'1 минута 30 секунд назад'
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
if isinstance(timestamp, (int, float)):
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
else:
|
||||
dt = timestamp
|
||||
|
||||
# Вычисляем разницу
|
||||
delta = now - dt
|
||||
is_past = delta.total_seconds() > 0
|
||||
|
||||
seconds = abs(int(delta.total_seconds()))
|
||||
|
||||
# Если меньше минуты
|
||||
if seconds < 60:
|
||||
if is_past:
|
||||
return "только что"
|
||||
else:
|
||||
return "сейчас"
|
||||
|
||||
# Форматируем длительность
|
||||
if detailed:
|
||||
duration = format_duration(seconds, TimeFormat.FULL, max_units=2)
|
||||
else:
|
||||
duration = format_duration(seconds, TimeFormat.FULL, max_units=1)
|
||||
|
||||
if is_past:
|
||||
return f"{duration} назад"
|
||||
else:
|
||||
return f"через {duration}"
|
||||
|
||||
|
||||
def parse_duration(duration_str: str) -> Optional[int]:
|
||||
"""
|
||||
Парсит строку длительности в секунды.
|
||||
|
||||
Args:
|
||||
duration_str: Строка длительности (например "1ч 30м", "2h 15m", "90s")
|
||||
|
||||
Returns:
|
||||
Optional[int]: Длительность в секундах или None если не удалось распарсить
|
||||
|
||||
Example:
|
||||
>> parse_duration("1ч 30м")
|
||||
5400
|
||||
|
||||
>> parse_duration("2h 15m 30s")
|
||||
8130
|
||||
|
||||
>> parse_duration("90s")
|
||||
90
|
||||
"""
|
||||
import re
|
||||
|
||||
# Паттерны для разных единиц
|
||||
patterns = {
|
||||
'weeks': r'(\d+)\s*(?:нед|w|week|weeks)',
|
||||
'days': r'(\d+)\s*(?:д|d|day|days)',
|
||||
'hours': r'(\d+)\s*(?:ч|h|hour|hours)',
|
||||
'minutes': r'(\d+)\s*(?:м|m|min|minutes)',
|
||||
'seconds': r'(\d+)\s*(?:с|s|sec|seconds)'
|
||||
}
|
||||
|
||||
total_seconds = 0
|
||||
|
||||
# Ищем каждую единицу
|
||||
for unit, pattern in patterns.items():
|
||||
match = re.search(pattern, duration_str, re.IGNORECASE)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
|
||||
if unit == 'weeks':
|
||||
total_seconds += value * 604800
|
||||
elif unit == 'days':
|
||||
total_seconds += value * 86400
|
||||
elif unit == 'hours':
|
||||
total_seconds += value * 3600
|
||||
elif unit == 'minutes':
|
||||
total_seconds += value * 60
|
||||
elif unit == 'seconds':
|
||||
total_seconds += value
|
||||
|
||||
return total_seconds if total_seconds > 0 else None
|
||||
|
||||
|
||||
# ================= ДОПОЛНИТЕЛЬНЫЕ УТИЛИТЫ =================
|
||||
|
||||
def seconds_to_human(seconds: int) -> str:
|
||||
"""
|
||||
Преобразует секунды в человекопонятный формат (самая большая единица).
|
||||
|
||||
Args:
|
||||
seconds: Количество секунд
|
||||
|
||||
Returns:
|
||||
str: Человекопонятный формат
|
||||
|
||||
Example:
|
||||
>> seconds_to_human(3600)
|
||||
'1 час'
|
||||
|
||||
>> seconds_to_human(90)
|
||||
'1.5 минуты'
|
||||
"""
|
||||
if seconds >= 604800: # Неделя
|
||||
weeks = seconds / 604800
|
||||
week_form = get_plural_form(int(weeks), ('неделя', 'недели', 'недель'))
|
||||
return f"{weeks:.1f} {week_form}".replace('.0', '')
|
||||
elif seconds >= 86400: # День
|
||||
days = seconds / 86400
|
||||
day_form = get_plural_form(int(days), ('день', 'дня', 'дней'))
|
||||
return f"{days:.1f} {day_form}".replace('.0', '')
|
||||
elif seconds >= 3600: # Час
|
||||
hours = seconds / 3600
|
||||
hour_form = get_plural_form(int(hours), ('час', 'часа', 'часов'))
|
||||
return f"{hours:.1f} {hour_form}".replace('.0', '')
|
||||
elif seconds >= 60: # Минута
|
||||
minutes = seconds / 60
|
||||
minute_form = get_plural_form(int(minutes), ('минута', 'минуты', 'минут'))
|
||||
return f"{minutes:.1f} {minute_form}".replace('.0', '')
|
||||
else: # Секунда
|
||||
second_form = get_plural_form(seconds, ('секунда', 'секунды', 'секунд'))
|
||||
return f"{seconds} {second_form}"
|
||||
|
||||
|
||||
def time_until(target_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str:
|
||||
"""
|
||||
Возвращает время до указанного момента.
|
||||
|
||||
Args:
|
||||
target_time: Целевое время
|
||||
format_type: Тип форматирования
|
||||
|
||||
Returns:
|
||||
str: Отформатированное время
|
||||
|
||||
Example:
|
||||
>> target = datetime.now() + timedelta(hours=2, minutes=30)
|
||||
>> time_until(target)
|
||||
'2 часа 30 минут'
|
||||
"""
|
||||
now = datetime.now()
|
||||
delta = target_time - now
|
||||
|
||||
if delta.total_seconds() <= 0:
|
||||
return "уже прошло"
|
||||
|
||||
seconds = int(delta.total_seconds())
|
||||
return format_duration(seconds, format_type=format_type)
|
||||
|
||||
|
||||
def time_since(start_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str:
|
||||
"""
|
||||
Возвращает время с указанного момента.
|
||||
|
||||
Args:
|
||||
start_time: Начальное время
|
||||
format_type: Тип форматирования
|
||||
|
||||
Returns:
|
||||
str: Отформатированное время
|
||||
|
||||
Example:
|
||||
>> start = datetime.now() - timedelta(hours=1, minutes=15)
|
||||
>> time_since(start)
|
||||
'1 час 15 минут'
|
||||
"""
|
||||
now = datetime.now()
|
||||
delta = now - start_time
|
||||
|
||||
if delta.total_seconds() <= 0:
|
||||
return "еще не началось"
|
||||
|
||||
seconds = int(delta.total_seconds())
|
||||
return format_duration(seconds, format_type=format_type)
|
||||
|
||||
|
||||
def format_date_range(start: datetime, end: datetime) -> str:
|
||||
"""
|
||||
Форматирует диапазон дат.
|
||||
|
||||
Args:
|
||||
start: Начальная дата
|
||||
end: Конечная дата
|
||||
|
||||
Returns:
|
||||
str: Отформатированный диапазон
|
||||
|
||||
Example:
|
||||
>> start = datetime(2026, 2, 17, 10, 0)
|
||||
>> end = datetime(2026, 2, 17, 18, 0)
|
||||
>> format_date_range(start, end)
|
||||
'17.02.2026 с 10:00 до 18:00'
|
||||
"""
|
||||
if start.date() == end.date():
|
||||
# Один день
|
||||
return f"{start.strftime('%d.%m.%Y')} с {start.strftime('%H:%M')} до {end.strftime('%H:%M')}"
|
||||
else:
|
||||
# Разные дни
|
||||
return f"с {start.strftime('%d.%m.%Y %H:%M')} до {end.strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
|
||||
def is_today(dt: datetime) -> bool:
|
||||
"""Проверяет, является ли дата сегодняшней"""
|
||||
return dt.date() == datetime.now().date()
|
||||
|
||||
|
||||
def is_yesterday(dt: datetime) -> bool:
|
||||
"""Проверяет, является ли дата вчерашней"""
|
||||
yesterday = datetime.now().date() - timedelta(days=1)
|
||||
return dt.date() == yesterday
|
||||
|
||||
|
||||
def is_tomorrow(dt: datetime) -> bool:
|
||||
"""Проверяет, является ли дата завтрашней"""
|
||||
tomorrow = datetime.now().date() + timedelta(days=1)
|
||||
return dt.date() == tomorrow
|
||||
|
||||
|
||||
def smart_date(dt: datetime) -> str:
|
||||
"""
|
||||
Умное форматирование даты (сегодня, вчера, завтра, или дата).
|
||||
|
||||
Args:
|
||||
dt: Дата для форматирования
|
||||
|
||||
Returns:
|
||||
str: Отформатированная дата
|
||||
|
||||
Example:
|
||||
>> smart_date(datetime.now())
|
||||
'сегодня в 14:30'
|
||||
|
||||
>> smart_date(datetime.now() - timedelta(days=1))
|
||||
'вчера в 20:15'
|
||||
"""
|
||||
if is_today(dt):
|
||||
return f"сегодня в {dt.strftime('%H:%M')}"
|
||||
elif is_yesterday(dt):
|
||||
return f"вчера в {dt.strftime('%H:%M')}"
|
||||
elif is_tomorrow(dt):
|
||||
return f"завтра в {dt.strftime('%H:%M')}"
|
||||
else:
|
||||
# Если в этом году, не показываем год
|
||||
if dt.year == datetime.now().year:
|
||||
return dt.strftime('%d.%m в %H:%M')
|
||||
else:
|
||||
return dt.strftime('%d.%m.%Y в %H:%M')
|
||||
504
bot/utils/hidden_username.py
Normal file
504
bot/utils/hidden_username.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
Утилиты для упоминаний пользователей (mentions)
|
||||
"""
|
||||
from typing import Optional, List, Set
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message, ChatMemberAdministrator, ChatMemberOwner, User
|
||||
from aiogram.utils.markdown import hide_link, hlink
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
|
||||
__all__ = (
|
||||
'mention_admins',
|
||||
'mention_user',
|
||||
'mention_users',
|
||||
'get_admins_list',
|
||||
'AdminCache',
|
||||
'admin_cache',
|
||||
'mention_moderators',
|
||||
'mention_owner',
|
||||
'hidden_admins_message'
|
||||
)
|
||||
|
||||
|
||||
class AdminCache:
|
||||
"""
|
||||
Кэш для списков администраторов чатов.
|
||||
|
||||
Уменьшает количество запросов к API Telegram.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: int = 300):
|
||||
"""
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут)
|
||||
"""
|
||||
self.ttl = ttl
|
||||
# {chat_id: (admins_list, timestamp)}
|
||||
self._cache: dict[int, tuple[List[User], datetime]] = {}
|
||||
# Статистика
|
||||
self.hits: int = 0
|
||||
self.misses: int = 0
|
||||
|
||||
def get(self, chat_id: int) -> Optional[List[User]]:
|
||||
"""
|
||||
Получает список админов из кэша.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
List[User] или None если кэш устарел
|
||||
"""
|
||||
if chat_id in self._cache:
|
||||
admins, timestamp = self._cache[chat_id]
|
||||
|
||||
# Проверяем актуальность
|
||||
if datetime.now() - timestamp < timedelta(seconds=self.ttl):
|
||||
self.hits += 1
|
||||
return admins
|
||||
else:
|
||||
# Удаляем устаревшую запись
|
||||
del self._cache[chat_id]
|
||||
|
||||
self.misses += 1
|
||||
return None
|
||||
|
||||
def set(self, chat_id: int, admins: List[User]) -> None:
|
||||
"""
|
||||
Сохраняет список админов в кэш.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
admins: Список администраторов
|
||||
"""
|
||||
self._cache[chat_id] = (admins, datetime.now())
|
||||
|
||||
def invalidate(self, chat_id: Optional[int] = None) -> None:
|
||||
"""
|
||||
Инвалидирует кэш.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата (если None, очищает весь кэш)
|
||||
"""
|
||||
if chat_id is None:
|
||||
self._cache.clear()
|
||||
elif chat_id in self._cache:
|
||||
del self._cache[chat_id]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Удаляет устаревшие записи.
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных записей
|
||||
"""
|
||||
now = datetime.now()
|
||||
expired = [
|
||||
chat_id for chat_id, (_, timestamp) in self._cache.items()
|
||||
if now - timestamp >= timedelta(seconds=self.ttl)
|
||||
]
|
||||
|
||||
for chat_id in expired:
|
||||
del self._cache[chat_id]
|
||||
|
||||
return len(expired)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Возвращает статистику кэша"""
|
||||
total = self.hits + self.misses
|
||||
hit_rate = (self.hits / total * 100) if total > 0 else 0
|
||||
|
||||
return {
|
||||
'hits': self.hits,
|
||||
'misses': self.misses,
|
||||
'hit_rate': f"{hit_rate:.1f}%",
|
||||
'cached_chats': len(self._cache)
|
||||
}
|
||||
|
||||
|
||||
# Глобальный кэш
|
||||
admin_cache = AdminCache(ttl=300)
|
||||
|
||||
|
||||
async def get_admins_list(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
exclude_bots: bool = True,
|
||||
exclude_users: Optional[Set[int]] = None,
|
||||
include_owner_only: bool = False,
|
||||
use_cache: bool = True
|
||||
) -> List[User]:
|
||||
"""
|
||||
Получает список администраторов чата.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
exclude_bots: Исключить ботов
|
||||
exclude_users: Множество ID пользователей для исключения
|
||||
include_owner_only: Только владелец чата
|
||||
use_cache: Использовать кэш
|
||||
|
||||
Returns:
|
||||
List[User]: Список администраторов
|
||||
|
||||
Example:
|
||||
>> admins = await get_admins_list(bot, chat_id)
|
||||
>> print(f"Администраторов: {len(admins)}")
|
||||
"""
|
||||
# Проверяем кэш
|
||||
if use_cache:
|
||||
cached_admins = admin_cache.get(chat_id)
|
||||
if cached_admins is not None:
|
||||
admins = cached_admins.copy()
|
||||
else:
|
||||
# Загружаем из API
|
||||
try:
|
||||
chat_admins = await bot.get_chat_administrators(chat_id)
|
||||
admins = [admin.user for admin in chat_admins]
|
||||
# Сохраняем в кэш
|
||||
admin_cache.set(chat_id, admins)
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return []
|
||||
else:
|
||||
# Без кэша
|
||||
try:
|
||||
chat_admins = await bot.get_chat_administrators(chat_id)
|
||||
admins = [admin.user for admin in chat_admins]
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return []
|
||||
|
||||
# Фильтрация
|
||||
filtered_admins = []
|
||||
|
||||
for admin_user in admins:
|
||||
# Исключаем ботов
|
||||
if exclude_bots and admin_user.is_bot:
|
||||
continue
|
||||
|
||||
# Исключаем конкретных пользователей
|
||||
if exclude_users and admin_user.id in exclude_users:
|
||||
continue
|
||||
|
||||
filtered_admins.append(admin_user)
|
||||
|
||||
# Только владелец
|
||||
if include_owner_only and filtered_admins:
|
||||
# Получаем информацию о владельце
|
||||
try:
|
||||
chat_admins = await bot.get_chat_administrators(chat_id)
|
||||
owner = next(
|
||||
(admin.user for admin in chat_admins if isinstance(admin, ChatMemberOwner)),
|
||||
None
|
||||
)
|
||||
if owner:
|
||||
return [owner]
|
||||
except:
|
||||
pass
|
||||
|
||||
return filtered_admins
|
||||
|
||||
|
||||
async def mention_admins(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
text: str = "",
|
||||
format_type: str = "hidden",
|
||||
exclude_bots: bool = True,
|
||||
exclude_users: Optional[Set[int]] = None,
|
||||
separator: str = " ",
|
||||
use_cache: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Формирует текст с упоминанием всех администраторов.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
text: Основной текст сообщения
|
||||
format_type: Тип форматирования:
|
||||
- 'hidden': Скрытые ссылки (невидимые)
|
||||
- 'mention': HTML mentions (видимые имена)
|
||||
- 'username': @username (только для пользователей с username)
|
||||
- 'mixed': Mentions для пользователей с именами, hidden для остальных
|
||||
exclude_bots: Исключить ботов
|
||||
exclude_users: Множество ID пользователей для исключения
|
||||
separator: Разделитель между mentions (для видимых форматов)
|
||||
use_cache: Использовать кэш
|
||||
|
||||
Returns:
|
||||
str: Отформатированный текст с упоминаниями
|
||||
|
||||
Example:
|
||||
>> # Скрытые упоминания
|
||||
>> text = await mention_admins(bot, chat_id, "Внимание, админы!")
|
||||
>> await message.answer(text, parse_mode="HTML")
|
||||
|
||||
>> # Видимые упоминания
|
||||
>> text = await mention_admins(bot, chat_id, "Админы:", format_type="mention")
|
||||
>> await message.answer(text, parse_mode="HTML")
|
||||
"""
|
||||
# Получаем список админов
|
||||
admins = await get_admins_list(
|
||||
bot=bot,
|
||||
chat_id=chat_id,
|
||||
exclude_bots=exclude_bots,
|
||||
exclude_users=exclude_users,
|
||||
use_cache=use_cache
|
||||
)
|
||||
|
||||
if not admins:
|
||||
return text
|
||||
|
||||
# Формируем упоминания в зависимости от типа
|
||||
mentions = []
|
||||
|
||||
if format_type == "hidden":
|
||||
# Скрытые ссылки (невидимые)
|
||||
for admin in admins:
|
||||
mentions.append(hide_link(f"tg://user?id={admin.id}"))
|
||||
|
||||
# Объединяем все ссылки и добавляем текст
|
||||
return "".join(mentions) + text
|
||||
|
||||
elif format_type == "mention":
|
||||
# HTML mentions (видимые имена)
|
||||
for admin in admins:
|
||||
name = admin.full_name or admin.first_name or f"User {admin.id}"
|
||||
mentions.append(hlink(name, f"tg://user?id={admin.id}"))
|
||||
|
||||
mentions_text = separator.join(mentions)
|
||||
return f"{text}\n\n{mentions_text}" if text else mentions_text
|
||||
|
||||
elif format_type == "username":
|
||||
# Только @username
|
||||
for admin in admins:
|
||||
if admin.username:
|
||||
mentions.append(f"@{admin.username}")
|
||||
|
||||
if not mentions:
|
||||
# Fallback на hidden если нет username
|
||||
return await mention_admins(
|
||||
bot, chat_id, text, format_type="hidden",
|
||||
exclude_bots=exclude_bots, exclude_users=exclude_users
|
||||
)
|
||||
|
||||
mentions_text = separator.join(mentions)
|
||||
return f"{text}\n\n{mentions_text}" if text else mentions_text
|
||||
|
||||
elif format_type == "mixed":
|
||||
# Mentions для пользователей с именами, hidden для остальных
|
||||
hidden_links = []
|
||||
visible_mentions = []
|
||||
|
||||
for admin in admins:
|
||||
if admin.username:
|
||||
# Видимый mention
|
||||
name = admin.full_name or admin.first_name or f"@{admin.username}"
|
||||
visible_mentions.append(hlink(name, f"tg://user?id={admin.id}"))
|
||||
else:
|
||||
# Скрытая ссылка
|
||||
hidden_links.append(hide_link(f"tg://user?id={admin.id}"))
|
||||
|
||||
hidden_part = "".join(hidden_links)
|
||||
visible_part = separator.join(visible_mentions)
|
||||
|
||||
if text:
|
||||
if visible_part:
|
||||
return f"{hidden_part}{text}\n\n{visible_part}"
|
||||
else:
|
||||
return f"{hidden_part}{text}"
|
||||
else:
|
||||
return f"{hidden_part}{visible_part}"
|
||||
|
||||
# По умолчанию - hidden
|
||||
return text
|
||||
|
||||
|
||||
async def mention_user(
|
||||
user: User,
|
||||
format_type: str = "mention",
|
||||
show_username: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Создает упоминание одного пользователя.
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
format_type: Тип форматирования ('mention', 'hidden', 'username')
|
||||
show_username: Показывать username вместо имени (для mention)
|
||||
|
||||
Returns:
|
||||
str: Отформатированное упоминание
|
||||
|
||||
Example:
|
||||
>> mention = await mention_user(message.from_user)
|
||||
>> await message.answer(f"Привет, {mention}!", parse_mode="HTML")
|
||||
"""
|
||||
if format_type == "hidden":
|
||||
return hide_link(f"tg://user?id={user.id}")
|
||||
|
||||
elif format_type == "username":
|
||||
if user.username:
|
||||
return f"@{user.username}"
|
||||
# Fallback на mention
|
||||
return await mention_user(user, format_type="mention")
|
||||
|
||||
else: # mention
|
||||
if show_username and user.username:
|
||||
display_name = f"@{user.username}"
|
||||
else:
|
||||
display_name = user.full_name or user.first_name or f"User {user.id}"
|
||||
|
||||
return hlink(display_name, f"tg://user?id={user.id}")
|
||||
|
||||
|
||||
async def mention_users(
|
||||
users: List[User],
|
||||
format_type: str = "mention",
|
||||
separator: str = ", ",
|
||||
max_count: Optional[int] = None
|
||||
) -> str:
|
||||
"""
|
||||
Создает упоминания списка пользователей.
|
||||
|
||||
Args:
|
||||
users: Список пользователей
|
||||
format_type: Тип форматирования
|
||||
separator: Разделитель между упоминаниями
|
||||
max_count: Максимальное количество упоминаний (остальные как "и еще N")
|
||||
|
||||
Returns:
|
||||
str: Отформатированные упоминания
|
||||
|
||||
Example:
|
||||
>> users = [msg.from_user, ...]
|
||||
>> mentions = await mention_users(users, max_count=5)
|
||||
>> await message.answer(f"Участники: {mentions}", parse_mode="HTML")
|
||||
"""
|
||||
if not users:
|
||||
return ""
|
||||
|
||||
# Ограничиваем количество
|
||||
display_users = users[:max_count] if max_count else users
|
||||
remaining = len(users) - len(display_users) if max_count else 0
|
||||
|
||||
# Создаем упоминания
|
||||
mentions = []
|
||||
for user in display_users:
|
||||
mention = await mention_user(user, format_type=format_type)
|
||||
mentions.append(mention)
|
||||
|
||||
result = separator.join(mentions)
|
||||
|
||||
# Добавляем "и еще N"
|
||||
if remaining > 0:
|
||||
result += f" и еще {remaining}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ================= СПЕЦИАЛИЗИРОВАННЫЕ ФУНКЦИИ =================
|
||||
|
||||
async def mention_moderators(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
text: str = "",
|
||||
format_type: str = "hidden"
|
||||
) -> str:
|
||||
"""
|
||||
Упоминает только модераторов (администраторов с правами на удаление/бан).
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
text: Текст сообщения
|
||||
format_type: Тип форматирования
|
||||
|
||||
Returns:
|
||||
str: Текст с упоминаниями модераторов
|
||||
"""
|
||||
try:
|
||||
chat_admins = await bot.get_chat_administrators(chat_id)
|
||||
|
||||
# Фильтруем только модераторов
|
||||
moderators = []
|
||||
for admin in chat_admins:
|
||||
if admin.user.is_bot:
|
||||
continue
|
||||
|
||||
# Владелец всегда модератор
|
||||
if isinstance(admin, ChatMemberOwner):
|
||||
moderators.append(admin.user)
|
||||
continue
|
||||
|
||||
# Проверяем права администратора
|
||||
if isinstance(admin, ChatMemberAdministrator):
|
||||
if admin.can_delete_messages and admin.can_restrict_members:
|
||||
moderators.append(admin.user)
|
||||
|
||||
# Формируем упоминания
|
||||
if format_type == "hidden":
|
||||
mentions = "".join(hide_link(f"tg://user?id={mod.id}") for mod in moderators)
|
||||
return f"{mentions}{text}"
|
||||
else:
|
||||
mentions = []
|
||||
for mod in moderators:
|
||||
name = mod.full_name or mod.first_name or f"Moderator {mod.id}"
|
||||
mentions.append(hlink(name, f"tg://user?id={mod.id}"))
|
||||
|
||||
mentions_text = ", ".join(mentions)
|
||||
return f"{text}\n\n{mentions_text}" if text else mentions_text
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
return text
|
||||
|
||||
|
||||
async def mention_owner(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
format_type: str = "mention"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Получает упоминание владельца чата.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
format_type: Тип форматирования
|
||||
|
||||
Returns:
|
||||
Optional[str]: Упоминание владельца или None
|
||||
"""
|
||||
try:
|
||||
chat_admins = await bot.get_chat_administrators(chat_id)
|
||||
owner = next(
|
||||
(admin.user for admin in chat_admins if isinstance(admin, ChatMemberOwner)),
|
||||
None
|
||||
)
|
||||
|
||||
if owner:
|
||||
return await mention_user(owner, format_type=format_type)
|
||||
|
||||
except (TelegramBadRequest, TelegramForbiddenError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Алиас для обратной совместимости
|
||||
async def hidden_admins_message(message: Message, text: str = "") -> str:
|
||||
"""
|
||||
Алиас для mention_admins с format_type="hidden".
|
||||
|
||||
DEPRECATED: Используйте mention_admins() вместо этого.
|
||||
"""
|
||||
from bot import bot
|
||||
return await mention_admins(
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
text=text,
|
||||
format_type="hidden"
|
||||
)
|
||||
650
bot/utils/state_utils.py
Normal file
650
bot/utils/state_utils.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Утилиты для работы с FSM состояниями и обновлениями
|
||||
"""
|
||||
from typing import Optional, Any, Set, Union
|
||||
from contextlib import suppress
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State
|
||||
from aiogram.types import CallbackQuery, Message, ReplyKeyboardRemove
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from middleware.loggers import logger
|
||||
|
||||
__all__ = (
|
||||
'clear_state',
|
||||
'answer_callback',
|
||||
'safe_answer_callback',
|
||||
'safe_delete_message',
|
||||
'safe_edit_message',
|
||||
'clear_state_keep_data',
|
||||
'get_state_data',
|
||||
'set_state_data',
|
||||
'update_state_data',
|
||||
'is_state_active',
|
||||
'inline_clear',
|
||||
'status_clear',
|
||||
'delete_messages',
|
||||
'set_state_with_data',
|
||||
'get_or_create_data',
|
||||
'increment_state_value',
|
||||
'append_to_state_list',
|
||||
'remove_from_state_list',
|
||||
'toggle_state_flag',
|
||||
'debug_state'
|
||||
)
|
||||
|
||||
|
||||
# ================= РАБОТА С FSM СОСТОЯНИЯМИ =================
|
||||
|
||||
async def clear_state(
|
||||
state: FSMContext,
|
||||
log: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Очищает FSM состояние.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
log: Логировать очистку
|
||||
|
||||
Example:
|
||||
>> await clear_state(state)
|
||||
"""
|
||||
current_state = await state.get_state()
|
||||
|
||||
if log and current_state:
|
||||
logger.debug(
|
||||
f"Очистка FSM состояния: {current_state}",
|
||||
log_type='FSM'
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def clear_state_keep_data(
|
||||
state: FSMContext,
|
||||
keep_keys: Optional[Set[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Очищает FSM состояние, но сохраняет определенные данные.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
keep_keys: Множество ключей для сохранения
|
||||
|
||||
Example:
|
||||
>> # Очищаем состояние, но сохраняем user_id и language
|
||||
>> await clear_state_keep_data(state, keep_keys={'user_id', 'language'})
|
||||
"""
|
||||
if keep_keys:
|
||||
# Получаем текущие данные
|
||||
current_data = await state.get_data()
|
||||
|
||||
# Сохраняем только нужные ключи
|
||||
saved_data = {
|
||||
key: value for key, value in current_data.items()
|
||||
if key in keep_keys
|
||||
}
|
||||
|
||||
# Очищаем состояние
|
||||
await state.clear()
|
||||
|
||||
# Восстанавливаем сохраненные данные
|
||||
if saved_data:
|
||||
await state.update_data(**saved_data)
|
||||
|
||||
logger.debug(
|
||||
f"FSM очищен, сохранены ключи: {', '.join(keep_keys)}",
|
||||
log_type='FSM'
|
||||
)
|
||||
else:
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def get_state_data(
|
||||
state: FSMContext,
|
||||
key: Optional[str] = None,
|
||||
default: Any = None
|
||||
) -> Any:
|
||||
"""
|
||||
Получает данные из FSM состояния.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ для получения (если None, возвращает все данные)
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
Any: Данные из состояния
|
||||
|
||||
Example:
|
||||
>> # Получить все данные
|
||||
>> data = await get_state_data(state)
|
||||
|
||||
>> # Получить конкретный ключ
|
||||
>> user_id = await get_state_data(state, 'user_id')
|
||||
|
||||
>> # С значением по умолчанию
|
||||
>> lang = await get_state_data(state, 'language', default='ru')
|
||||
"""
|
||||
data = await state.get_data()
|
||||
|
||||
if key is None:
|
||||
return data
|
||||
|
||||
return data.get(key, default)
|
||||
|
||||
|
||||
async def set_state_data(
|
||||
state: FSMContext,
|
||||
key: str,
|
||||
value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Устанавливает данные в FSM состояние.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ
|
||||
value: Значение
|
||||
|
||||
Example:
|
||||
>> await set_state_data(state, 'user_id', 123456789)
|
||||
"""
|
||||
await state.update_data(**{key: value})
|
||||
|
||||
|
||||
async def update_state_data(
|
||||
state: FSMContext,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Обновляет несколько полей в FSM состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
**kwargs: Пары ключ-значение для обновления
|
||||
|
||||
Example:
|
||||
>> await update_state_data(
|
||||
... state,
|
||||
... user_id=123456789,
|
||||
... language='ru',
|
||||
... step=1
|
||||
... )
|
||||
"""
|
||||
await state.update_data(**kwargs)
|
||||
|
||||
|
||||
async def is_state_active(state: FSMContext) -> bool:
|
||||
"""
|
||||
Проверяет, активно ли какое-либо состояние.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
|
||||
Returns:
|
||||
bool: True если есть активное состояние
|
||||
|
||||
Example:
|
||||
>> if await is_state_active(state):
|
||||
... await message.answer("У вас есть незавершенное действие")
|
||||
"""
|
||||
current_state = await state.get_state()
|
||||
return current_state is not None
|
||||
|
||||
|
||||
# ================= РАБОТА С CALLBACK QUERIES =================
|
||||
|
||||
async def answer_callback(
|
||||
callback: CallbackQuery,
|
||||
text: Optional[str] = None,
|
||||
show_alert: bool = False,
|
||||
cache_time: int = 0
|
||||
) -> bool:
|
||||
"""
|
||||
Отвечает на callback query.
|
||||
|
||||
Args:
|
||||
callback: Callback query
|
||||
text: Текст уведомления
|
||||
show_alert: Показать как alert
|
||||
cache_time: Время кэширования
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
|
||||
Example:
|
||||
>> await answer_callback(callback, "✅ Готово!")
|
||||
>> await answer_callback(callback, "⚠️ Ошибка", show_alert=True)
|
||||
"""
|
||||
try:
|
||||
await callback.answer(text=text, show_alert=show_alert, cache_time=cache_time)
|
||||
return True
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(
|
||||
f"Не удалось ответить на callback: {e}",
|
||||
log_type='CALLBACK'
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def safe_answer_callback(
|
||||
callback: CallbackQuery,
|
||||
text: Optional[str] = None,
|
||||
show_alert: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Безопасно отвечает на callback query (подавляет ошибки).
|
||||
|
||||
Args:
|
||||
callback: Callback query
|
||||
text: Текст уведомления
|
||||
show_alert: Показать как alert
|
||||
|
||||
Example:
|
||||
>> await safe_answer_callback(callback, "✅ Готово!")
|
||||
"""
|
||||
with suppress(TelegramBadRequest):
|
||||
await callback.answer(text=text, show_alert=show_alert)
|
||||
|
||||
|
||||
# ================= РАБОТА С СООБЩЕНИЯМИ =================
|
||||
|
||||
async def safe_delete_message(
|
||||
message: Message,
|
||||
log: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Безопасно удаляет сообщение.
|
||||
|
||||
Args:
|
||||
message: Сообщение для удаления
|
||||
log: Логировать попытку удаления
|
||||
|
||||
Returns:
|
||||
bool: True если успешно удалено
|
||||
|
||||
Example:
|
||||
>> await safe_delete_message(message)
|
||||
"""
|
||||
try:
|
||||
await message.delete()
|
||||
|
||||
if log:
|
||||
logger.debug(
|
||||
f"Сообщение удалено: {message.message_id}",
|
||||
log_type='MESSAGE'
|
||||
)
|
||||
|
||||
return True
|
||||
except TelegramBadRequest as e:
|
||||
if log:
|
||||
logger.warning(
|
||||
f"Не удалось удалить сообщение: {e}",
|
||||
log_type='MESSAGE'
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def safe_edit_message(
|
||||
message: Message,
|
||||
text: str,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Безопасно редактирует сообщение.
|
||||
|
||||
Args:
|
||||
message: Сообщение для редактирования
|
||||
text: Новый текст
|
||||
**kwargs: Дополнительные параметры (reply_markup, parse_mode, и т.д.)
|
||||
|
||||
Returns:
|
||||
bool: True если успешно отредактировано
|
||||
|
||||
Example:
|
||||
>> await safe_edit_message(
|
||||
... message,
|
||||
... "Новый текст",
|
||||
... parse_mode="HTML"
|
||||
... )
|
||||
"""
|
||||
try:
|
||||
await message.edit_text(text, **kwargs)
|
||||
return True
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(
|
||||
f"Не удалось отредактировать сообщение: {e}",
|
||||
log_type='MESSAGE'
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_messages(
|
||||
chat_id: int,
|
||||
message_ids: list[int],
|
||||
bot
|
||||
) -> int:
|
||||
"""
|
||||
Удаляет несколько сообщений.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата
|
||||
message_ids: Список ID сообщений
|
||||
bot: Экземпляр бота
|
||||
|
||||
Returns:
|
||||
int: Количество успешно удаленных сообщений
|
||||
|
||||
Example:
|
||||
>> deleted = await delete_messages(
|
||||
... chat_id=message.chat.id,
|
||||
... message_ids=[123, 124, 125],
|
||||
... bot=bot
|
||||
... )
|
||||
>> print(f"Удалено {deleted} сообщений")
|
||||
"""
|
||||
deleted_count = 0
|
||||
|
||||
for message_id in message_ids:
|
||||
try:
|
||||
await bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
deleted_count += 1
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
# ================= КОМБИНИРОВАННЫЕ ФУНКЦИИ =================
|
||||
|
||||
async def inline_clear(update: Union[Message, CallbackQuery]) -> None:
|
||||
"""
|
||||
Очищает все инлайн взаимодействия (отвечает на callback).
|
||||
|
||||
Args:
|
||||
update: Объект обновления (Message или CallbackQuery)
|
||||
|
||||
Example:
|
||||
>> await inline_clear(callback)
|
||||
"""
|
||||
if isinstance(update, CallbackQuery):
|
||||
await safe_answer_callback(update)
|
||||
|
||||
|
||||
async def status_clear(
|
||||
update: Union[Message, CallbackQuery],
|
||||
state: FSMContext,
|
||||
keep_data: Optional[Set[str]] = None,
|
||||
remove_keyboard: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Полная очистка: состояние FSM + ответ на callback + удаление клавиатуры.
|
||||
|
||||
Args:
|
||||
update: Объект обновления
|
||||
state: Контекст FSM
|
||||
keep_data: Данные для сохранения
|
||||
remove_keyboard: Удалить клавиатуру (только для Message)
|
||||
|
||||
Example:
|
||||
>> # Полная очистка
|
||||
>> await status_clear(message, state)
|
||||
|
||||
>> # С сохранением данных
|
||||
>> await status_clear(
|
||||
... callback,
|
||||
... state,
|
||||
... keep_data={'user_id', 'language'}
|
||||
... )
|
||||
|
||||
>> # С удалением клавиатуры
|
||||
>> await status_clear(message, state, remove_keyboard=True)
|
||||
"""
|
||||
# Очищаем состояние
|
||||
if keep_data:
|
||||
await clear_state_keep_data(state, keep_keys=keep_data)
|
||||
else:
|
||||
await clear_state(state, log=True)
|
||||
|
||||
# Отвечаем на callback
|
||||
await inline_clear(update)
|
||||
|
||||
# Удаляем клавиатуру если нужно
|
||||
if remove_keyboard and isinstance(update, Message):
|
||||
with suppress(TelegramBadRequest):
|
||||
await update.answer(
|
||||
"Отменено",
|
||||
reply_markup=ReplyKeyboardRemove()
|
||||
)
|
||||
|
||||
|
||||
# ================= УТИЛИТЫ ДЛЯ РАБОТЫ С СОСТОЯНИЯМИ =================
|
||||
|
||||
async def set_state_with_data(
|
||||
state: FSMContext,
|
||||
new_state: State,
|
||||
**data
|
||||
) -> None:
|
||||
"""
|
||||
Устанавливает новое состояние и данные одновременно.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
new_state: Новое состояние
|
||||
**data: Данные для сохранения
|
||||
|
||||
Example:
|
||||
>> await set_state_with_data(
|
||||
... state,
|
||||
... FormStates.waiting_name,
|
||||
... user_id=123456789,
|
||||
... step=1
|
||||
... )
|
||||
"""
|
||||
await state.set_state(new_state)
|
||||
if data:
|
||||
await state.update_data(**data)
|
||||
|
||||
logger.debug(
|
||||
f"Установлено состояние: {new_state.state}",
|
||||
log_type='FSM'
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_data(
|
||||
state: FSMContext,
|
||||
key: str,
|
||||
factory: Any
|
||||
) -> Any:
|
||||
"""
|
||||
Получает данные из состояния или создает их если их нет.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ данных
|
||||
factory: Значение по умолчанию или функция для создания
|
||||
|
||||
Returns:
|
||||
Any: Данные из состояния или созданные
|
||||
|
||||
Example:
|
||||
>> # С простым значением
|
||||
>> items = await get_or_create_data(state, 'items', [])
|
||||
|
||||
>> # С функцией
|
||||
>> data = await get_or_create_data(state, 'data', lambda: {'count': 0})
|
||||
"""
|
||||
data = await state.get_data()
|
||||
|
||||
if key not in data:
|
||||
# Создаем значение
|
||||
if callable(factory):
|
||||
value = factory()
|
||||
else:
|
||||
value = factory
|
||||
|
||||
await state.update_data(**{key: value})
|
||||
return value
|
||||
|
||||
return data[key]
|
||||
|
||||
|
||||
async def increment_state_value(
|
||||
state: FSMContext,
|
||||
key: str,
|
||||
amount: int = 1
|
||||
) -> int:
|
||||
"""
|
||||
Инкрементирует числовое значение в состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ значения
|
||||
amount: Величина инкремента
|
||||
|
||||
Returns:
|
||||
int: Новое значение
|
||||
|
||||
Example:
|
||||
>> # Увеличиваем счетчик
|
||||
>> new_count = await increment_state_value(state, 'attempts')
|
||||
>> if new_count >= 3:
|
||||
... await message.answer("Слишком много попыток!")
|
||||
"""
|
||||
data = await state.get_data()
|
||||
current = data.get(key, 0)
|
||||
new_value = current + amount
|
||||
|
||||
await state.update_data(**{key: new_value})
|
||||
return new_value
|
||||
|
||||
|
||||
async def append_to_state_list(
|
||||
state: FSMContext,
|
||||
key: str,
|
||||
value: Any
|
||||
) -> list:
|
||||
"""
|
||||
Добавляет значение в список в состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ списка
|
||||
value: Значение для добавления
|
||||
|
||||
Returns:
|
||||
list: Обновленный список
|
||||
|
||||
Example:
|
||||
>> # Добавляем товар в корзину
|
||||
>> cart = await append_to_state_list(state, 'cart', product_id)
|
||||
>> await message.answer(f"В корзине {len(cart)} товаров")
|
||||
"""
|
||||
data = await state.get_data()
|
||||
current_list = data.get(key, [])
|
||||
|
||||
if not isinstance(current_list, list):
|
||||
current_list = []
|
||||
|
||||
current_list.append(value)
|
||||
await state.update_data(**{key: current_list})
|
||||
|
||||
return current_list
|
||||
|
||||
|
||||
async def remove_from_state_list(
|
||||
state: FSMContext,
|
||||
key: str,
|
||||
value: Any
|
||||
) -> list:
|
||||
"""
|
||||
Удаляет значение из списка в состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ списка
|
||||
value: Значение для удаления
|
||||
|
||||
Returns:
|
||||
list: Обновленный список
|
||||
|
||||
Example:
|
||||
>> # Удаляем товар из корзины
|
||||
>> cart = await remove_from_state_list(state, 'cart', product_id)
|
||||
"""
|
||||
data = await state.get_data()
|
||||
current_list = data.get(key, [])
|
||||
|
||||
if isinstance(current_list, list) and value in current_list:
|
||||
current_list.remove(value)
|
||||
await state.update_data(**{key: current_list})
|
||||
|
||||
return current_list
|
||||
|
||||
|
||||
async def toggle_state_flag(
|
||||
state: FSMContext,
|
||||
key: str
|
||||
) -> bool:
|
||||
"""
|
||||
Переключает boolean флаг в состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
key: Ключ флага
|
||||
|
||||
Returns:
|
||||
bool: Новое значение флага
|
||||
|
||||
Example:
|
||||
>> # Переключаем режим
|
||||
>> is_active = await toggle_state_flag(state, 'notifications')
|
||||
>> await message.answer(
|
||||
... f"Уведомления: {'включены' if is_active else 'выключены'}"
|
||||
... )
|
||||
"""
|
||||
data = await state.get_data()
|
||||
current = data.get(key, False)
|
||||
new_value = not current
|
||||
|
||||
await state.update_data(**{key: new_value})
|
||||
return new_value
|
||||
|
||||
|
||||
# ================= ОТЛАДКА =================
|
||||
|
||||
async def debug_state(state: FSMContext) -> str:
|
||||
"""
|
||||
Возвращает отладочную информацию о состоянии.
|
||||
|
||||
Args:
|
||||
state: Контекст FSM
|
||||
|
||||
Returns:
|
||||
str: Форматированная информация о состоянии
|
||||
|
||||
Example:
|
||||
>> debug_info = await debug_state(state)
|
||||
>> print(debug_info)
|
||||
"""
|
||||
current_state = await state.get_state()
|
||||
data = await state.get_data()
|
||||
|
||||
lines = [
|
||||
"🔍 <b>Debug FSM:</b>\n",
|
||||
f"📊 Состояние: <code>{current_state or 'None'}</code>\n",
|
||||
f"📦 Данных: {len(data)}\n"
|
||||
]
|
||||
|
||||
if data:
|
||||
lines.append("\n<b>Данные:</b>")
|
||||
for key, value in data.items():
|
||||
value_str = str(value)
|
||||
if len(value_str) > 50:
|
||||
value_str = value_str[:50] + "..."
|
||||
lines.append(f"• {key}: <code>{value_str}</code>")
|
||||
|
||||
return "\n".join(lines)
|
||||
613
bot/utils/type_message.py
Normal file
613
bot/utils/type_message.py
Normal file
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Утилиты для работы с типами контента и чатов
|
||||
"""
|
||||
from typing import Final, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from aiogram.types import Message
|
||||
from aiogram.enums import ContentType, ChatType
|
||||
|
||||
__all__ = (
|
||||
'CHAT_TYPES_RU',
|
||||
'CONTENT_TYPES_RU',
|
||||
'CONTENT_EMOJI',
|
||||
'get_chat_type',
|
||||
'get_content_type',
|
||||
'get_content_text',
|
||||
'get_content_emoji',
|
||||
'get_media_info',
|
||||
'has_media',
|
||||
'has_text',
|
||||
'format_content_info',
|
||||
'ContentCategory',
|
||||
'get_content_category',
|
||||
'is_private_chat',
|
||||
'is_group_chat',
|
||||
'is_channel',
|
||||
'type_msg',
|
||||
'type_chat'
|
||||
)
|
||||
|
||||
# ==================== КОНСТАНТЫ ====================
|
||||
|
||||
# Типы чатов на русском
|
||||
CHAT_TYPES_RU: Final[Dict[str, str]] = {
|
||||
ChatType.PRIVATE: "Личные сообщения",
|
||||
ChatType.GROUP: "Группа",
|
||||
ChatType.SUPERGROUP: "Супергруппа",
|
||||
ChatType.CHANNEL: "Канал",
|
||||
"private": "Личные сообщения",
|
||||
"group": "Группа",
|
||||
"supergroup": "Супергруппа",
|
||||
"channel": "Канал",
|
||||
}
|
||||
|
||||
# Типы контента на русском
|
||||
CONTENT_TYPES_RU: Final[Dict[str, str]] = {
|
||||
# Текст и медиа
|
||||
ContentType.TEXT: "Текст",
|
||||
ContentType.ANIMATION: "GIF анимация",
|
||||
ContentType.AUDIO: "Аудиофайл",
|
||||
ContentType.DOCUMENT: "Документ",
|
||||
ContentType.PHOTO: "Фотография",
|
||||
ContentType.STICKER: "Стикер",
|
||||
ContentType.VIDEO: "Видео",
|
||||
ContentType.VIDEO_NOTE: "Видеосообщение",
|
||||
ContentType.VOICE: "Голосовое сообщение",
|
||||
|
||||
# Контакты и локации
|
||||
ContentType.CONTACT: "Контакт",
|
||||
ContentType.LOCATION: "Геолокация",
|
||||
ContentType.VENUE: "Место на карте",
|
||||
|
||||
# Игры и развлечения
|
||||
ContentType.DICE: "Игральная кость",
|
||||
ContentType.GAME: "Игра",
|
||||
ContentType.POLL: "Опрос",
|
||||
|
||||
# События чата
|
||||
ContentType.NEW_CHAT_MEMBERS: "Новые участники",
|
||||
ContentType.LEFT_CHAT_MEMBER: "Участник покинул чат",
|
||||
ContentType.NEW_CHAT_TITLE: "Изменено название чата",
|
||||
ContentType.NEW_CHAT_PHOTO: "Изменена аватарка чата",
|
||||
ContentType.DELETE_CHAT_PHOTO: "Удалена аватарка чата",
|
||||
ContentType.GROUP_CHAT_CREATED: "Группа создана",
|
||||
ContentType.SUPERGROUP_CHAT_CREATED: "Супергруппа создана",
|
||||
ContentType.CHANNEL_CHAT_CREATED: "Канал создан",
|
||||
ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED: "Изменён таймер автоудаления",
|
||||
ContentType.MIGRATE_TO_CHAT_ID: "Миграция в супергруппу",
|
||||
ContentType.MIGRATE_FROM_CHAT_ID: "Миграция из группы",
|
||||
ContentType.PINNED_MESSAGE: "Закреплено сообщение",
|
||||
|
||||
# Платежи
|
||||
ContentType.INVOICE: "Счёт на оплату",
|
||||
ContentType.SUCCESSFUL_PAYMENT: "Успешная оплата",
|
||||
|
||||
# Другое
|
||||
ContentType.CONNECTED_WEBSITE: "Подключён сайт",
|
||||
ContentType.PASSPORT_DATA: "Данные Telegram Passport",
|
||||
ContentType.PROXIMITY_ALERT_TRIGGERED: "Сработал алерт приближения",
|
||||
|
||||
# Видеочаты
|
||||
ContentType.VIDEO_CHAT_SCHEDULED: "Запланирован видеочат",
|
||||
ContentType.VIDEO_CHAT_STARTED: "Начался видеочат",
|
||||
ContentType.VIDEO_CHAT_ENDED: "Завершён видеочат",
|
||||
ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED: "Приглашены в видеочат",
|
||||
|
||||
# Web App
|
||||
ContentType.WEB_APP_DATA: "Данные Web App",
|
||||
|
||||
# Форумы
|
||||
ContentType.FORUM_TOPIC_CREATED: "Создана тема форума",
|
||||
ContentType.FORUM_TOPIC_EDITED: "Изменена тема форума",
|
||||
ContentType.FORUM_TOPIC_CLOSED: "Закрыта тема форума",
|
||||
ContentType.FORUM_TOPIC_REOPENED: "Открыта тема форума",
|
||||
ContentType.GENERAL_FORUM_TOPIC_HIDDEN: "Скрыта общая тема",
|
||||
ContentType.GENERAL_FORUM_TOPIC_UNHIDDEN: "Показана общая тема",
|
||||
|
||||
# Розыгрыши
|
||||
ContentType.GIVEAWAY_CREATED: "Создан розыгрыш",
|
||||
ContentType.GIVEAWAY: "Розыгрыш",
|
||||
ContentType.GIVEAWAY_WINNERS: "Победители розыгрыша",
|
||||
ContentType.GIVEAWAY_COMPLETED: "Завершён розыгрыш",
|
||||
|
||||
# Истории и реакции
|
||||
ContentType.STORY: "История",
|
||||
}
|
||||
|
||||
# Эмодзи для типов контента
|
||||
CONTENT_EMOJI: Final[Dict[str, str]] = {
|
||||
ContentType.TEXT: "💬",
|
||||
ContentType.ANIMATION: "🎞️",
|
||||
ContentType.AUDIO: "🎵",
|
||||
ContentType.DOCUMENT: "📄",
|
||||
ContentType.PHOTO: "📷",
|
||||
ContentType.STICKER: "🎨",
|
||||
ContentType.VIDEO: "🎥",
|
||||
ContentType.VIDEO_NOTE: "🎬",
|
||||
ContentType.VOICE: "🎤",
|
||||
ContentType.CONTACT: "👤",
|
||||
ContentType.LOCATION: "📍",
|
||||
ContentType.VENUE: "🏢",
|
||||
ContentType.DICE: "🎲",
|
||||
ContentType.GAME: "🎮",
|
||||
ContentType.POLL: "📊",
|
||||
ContentType.INVOICE: "💰",
|
||||
ContentType.SUCCESSFUL_PAYMENT: "✅",
|
||||
}
|
||||
|
||||
|
||||
class ContentCategory(str, Enum):
|
||||
"""Категории контента"""
|
||||
TEXT = "text" # Текстовые сообщения
|
||||
MEDIA = "media" # Медиа (фото, видео, и т.д.)
|
||||
FILE = "file" # Файлы и документы
|
||||
VOICE = "voice" # Голосовые сообщения
|
||||
LOCATION = "location" # Локации и места
|
||||
INTERACTION = "interaction" # Игры, опросы, кости
|
||||
SERVICE = "service" # Служебные сообщения
|
||||
PAYMENT = "payment" # Платежи
|
||||
UNKNOWN = "unknown" # Неизвестный тип
|
||||
|
||||
|
||||
# ==================== ОСНОВНЫЕ ФУНКЦИИ ====================
|
||||
|
||||
def get_chat_type(message: Message, russian: bool = True) -> str:
|
||||
"""
|
||||
Возвращает тип чата.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
russian: Вернуть на русском языке
|
||||
|
||||
Returns:
|
||||
str: Тип чата
|
||||
|
||||
Example:
|
||||
>>> get_chat_type(message)
|
||||
'Личные сообщения'
|
||||
>>> get_chat_type(message, russian=False)
|
||||
'private'
|
||||
"""
|
||||
chat_type = message.chat.type
|
||||
|
||||
if russian:
|
||||
return CHAT_TYPES_RU.get(chat_type, f"Неизвестный тип ({chat_type})")
|
||||
|
||||
return chat_type
|
||||
|
||||
|
||||
def get_content_type(message: Message, russian: bool = True) -> str:
|
||||
"""
|
||||
Возвращает тип контента сообщения.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
russian: Вернуть на русском языке
|
||||
|
||||
Returns:
|
||||
str: Тип контента
|
||||
|
||||
Example:
|
||||
>>> get_content_type(message)
|
||||
'Фотография'
|
||||
>>> get_content_type(message, russian=False)
|
||||
'photo'
|
||||
"""
|
||||
content_type = message.content_type
|
||||
|
||||
if russian:
|
||||
return CONTENT_TYPES_RU.get(content_type, f"Неизвестный тип ({content_type})")
|
||||
|
||||
return content_type
|
||||
|
||||
|
||||
def get_content_emoji(message: Message) -> str:
|
||||
"""
|
||||
Возвращает эмодзи для типа контента.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
str: Эмодзи
|
||||
|
||||
Example:
|
||||
>>> get_content_emoji(message)
|
||||
'📷'
|
||||
"""
|
||||
return CONTENT_EMOJI.get(message.content_type, "📎")
|
||||
|
||||
|
||||
def get_content_text(message: Message, max_length: Optional[int] = None) -> Optional[str]:
|
||||
"""
|
||||
Извлекает текст из сообщения (текст или caption).
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
max_length: Максимальная длина текста (обрезает если больше)
|
||||
|
||||
Returns:
|
||||
Optional[str]: Текст сообщения или None
|
||||
|
||||
Example:
|
||||
>>> get_content_text(message)
|
||||
'Привет, мир!'
|
||||
|
||||
>>> get_content_text(message) # Фото с подписью
|
||||
'Красивое фото'
|
||||
|
||||
>>> get_content_text(message, max_length=10)
|
||||
'Привет,...'
|
||||
"""
|
||||
text = message.text or message.caption
|
||||
|
||||
if text and max_length and len(text) > max_length:
|
||||
return f"{text[:max_length]}..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def has_media(message: Message) -> bool:
|
||||
"""
|
||||
Проверяет, содержит ли сообщение медиа.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
bool: True если есть медиа
|
||||
|
||||
Example:
|
||||
>>> has_media(message)
|
||||
True
|
||||
"""
|
||||
media_types = {
|
||||
ContentType.PHOTO,
|
||||
ContentType.VIDEO,
|
||||
ContentType.ANIMATION,
|
||||
ContentType.AUDIO,
|
||||
ContentType.VOICE,
|
||||
ContentType.VIDEO_NOTE,
|
||||
ContentType.DOCUMENT,
|
||||
ContentType.STICKER
|
||||
}
|
||||
|
||||
return message.content_type in media_types
|
||||
|
||||
|
||||
def has_text(message: Message) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли в сообщении текст (или caption).
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
bool: True если есть текст
|
||||
|
||||
Example:
|
||||
>>> has_text(message)
|
||||
True
|
||||
"""
|
||||
return bool(message.text or message.caption)
|
||||
|
||||
|
||||
# ==================== ДЕТАЛЬНАЯ ИНФОРМАЦИЯ О МЕДИА ====================
|
||||
|
||||
def get_media_info(message: Message) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает детальную информацию о медиа в сообщении.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
Dict: Словарь с информацией о медиа
|
||||
|
||||
Example:
|
||||
>>> get_media_info(message)
|
||||
{
|
||||
'type': 'photo',
|
||||
'type_ru': 'Фотография',
|
||||
'emoji': '📷',
|
||||
'has_caption': True,
|
||||
'caption': 'Красивое фото',
|
||||
'file_size': 123456,
|
||||
'file_size_mb': 0.12,
|
||||
'width': 1920,
|
||||
'height': 1080,
|
||||
'duration': None
|
||||
}
|
||||
"""
|
||||
info = {
|
||||
'type': message.content_type,
|
||||
'type_ru': get_content_type(message),
|
||||
'emoji': get_content_emoji(message),
|
||||
'has_caption': bool(message.caption),
|
||||
'caption': message.caption,
|
||||
'has_text': bool(message.text),
|
||||
'text': message.text,
|
||||
}
|
||||
|
||||
# Фото
|
||||
if message.photo:
|
||||
largest_photo = max(message.photo, key=lambda p: p.file_size or 0)
|
||||
info.update({
|
||||
'file_id': largest_photo.file_id,
|
||||
'file_unique_id': largest_photo.file_unique_id,
|
||||
'file_size': largest_photo.file_size,
|
||||
'file_size_kb': round(largest_photo.file_size / 1024, 2) if largest_photo.file_size else None,
|
||||
'width': largest_photo.width,
|
||||
'height': largest_photo.height,
|
||||
'count': len(message.photo) # Количество размеров
|
||||
})
|
||||
|
||||
# Видео
|
||||
elif message.video:
|
||||
info.update({
|
||||
'file_id': message.video.file_id,
|
||||
'file_unique_id': message.video.file_unique_id,
|
||||
'file_size': message.video.file_size,
|
||||
'file_size_mb': round(message.video.file_size / (1024 * 1024), 2) if message.video.file_size else None,
|
||||
'width': message.video.width,
|
||||
'height': message.video.height,
|
||||
'duration': message.video.duration,
|
||||
'duration_formatted': _format_duration(message.video.duration) if message.video.duration else None,
|
||||
'mime_type': message.video.mime_type,
|
||||
'file_name': message.video.file_name
|
||||
})
|
||||
|
||||
# Документ
|
||||
elif message.document:
|
||||
info.update({
|
||||
'file_id': message.document.file_id,
|
||||
'file_unique_id': message.document.file_unique_id,
|
||||
'file_size': message.document.file_size,
|
||||
'file_size_mb': round(message.document.file_size / (1024 * 1024),
|
||||
2) if message.document.file_size else None,
|
||||
'file_name': message.document.file_name,
|
||||
'mime_type': message.document.mime_type
|
||||
})
|
||||
|
||||
# Аудио
|
||||
elif message.audio:
|
||||
info.update({
|
||||
'file_id': message.audio.file_id,
|
||||
'file_unique_id': message.audio.file_unique_id,
|
||||
'file_size': message.audio.file_size,
|
||||
'file_size_mb': round(message.audio.file_size / (1024 * 1024), 2) if message.audio.file_size else None,
|
||||
'duration': message.audio.duration,
|
||||
'duration_formatted': _format_duration(message.audio.duration) if message.audio.duration else None,
|
||||
'performer': message.audio.performer,
|
||||
'title': message.audio.title,
|
||||
'mime_type': message.audio.mime_type,
|
||||
'file_name': message.audio.file_name
|
||||
})
|
||||
|
||||
# Голосовое сообщение
|
||||
elif message.voice:
|
||||
info.update({
|
||||
'file_id': message.voice.file_id,
|
||||
'file_unique_id': message.voice.file_unique_id,
|
||||
'file_size': message.voice.file_size,
|
||||
'file_size_kb': round(message.voice.file_size / 1024, 2) if message.voice.file_size else None,
|
||||
'duration': message.voice.duration,
|
||||
'duration_formatted': _format_duration(message.voice.duration) if message.voice.duration else None,
|
||||
'mime_type': message.voice.mime_type
|
||||
})
|
||||
|
||||
# Видеосообщение
|
||||
elif message.video_note:
|
||||
info.update({
|
||||
'file_id': message.video_note.file_id,
|
||||
'file_unique_id': message.video_note.file_unique_id,
|
||||
'file_size': message.video_note.file_size,
|
||||
'file_size_kb': round(message.video_note.file_size / 1024, 2) if message.video_note.file_size else None,
|
||||
'duration': message.video_note.duration,
|
||||
'duration_formatted': _format_duration(
|
||||
message.video_note.duration) if message.video_note.duration else None,
|
||||
'length': message.video_note.length # Диаметр
|
||||
})
|
||||
|
||||
# Анимация (GIF)
|
||||
elif message.animation:
|
||||
info.update({
|
||||
'file_id': message.animation.file_id,
|
||||
'file_unique_id': message.animation.file_unique_id,
|
||||
'file_size': message.animation.file_size,
|
||||
'file_size_mb': round(message.animation.file_size / (1024 * 1024),
|
||||
2) if message.animation.file_size else None,
|
||||
'width': message.animation.width,
|
||||
'height': message.animation.height,
|
||||
'duration': message.animation.duration,
|
||||
'duration_formatted': _format_duration(message.animation.duration) if message.animation.duration else None,
|
||||
'mime_type': message.animation.mime_type,
|
||||
'file_name': message.animation.file_name
|
||||
})
|
||||
|
||||
# Стикер
|
||||
elif message.sticker:
|
||||
info.update({
|
||||
'file_id': message.sticker.file_id,
|
||||
'file_unique_id': message.sticker.file_unique_id,
|
||||
'file_size': message.sticker.file_size,
|
||||
'width': message.sticker.width,
|
||||
'height': message.sticker.height,
|
||||
'is_animated': message.sticker.is_animated,
|
||||
'is_video': message.sticker.is_video,
|
||||
'emoji': message.sticker.emoji,
|
||||
'set_name': message.sticker.set_name
|
||||
})
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def format_content_info(message: Message, include_text: bool = True, max_text_length: int = 50) -> str:
|
||||
"""
|
||||
Форматирует информацию о контенте в читаемую строку.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
include_text: Включать текст/caption в описание
|
||||
max_text_length: Максимальная длина текста
|
||||
|
||||
Returns:
|
||||
str: Отформатированная строка
|
||||
|
||||
Example:
|
||||
>>> format_content_info(message)
|
||||
'📷 Фотография (1920x1080, 123 KB) + "Красивое фото"'
|
||||
|
||||
>>> format_content_info(message)
|
||||
'🎥 Видео (1920x1080, 5.2 MB, 1:30) + "Смотрите это видео"'
|
||||
"""
|
||||
emoji = get_content_emoji(message)
|
||||
content_type = get_content_type(message)
|
||||
|
||||
parts = [f"{emoji} {content_type}"]
|
||||
|
||||
# Добавляем детали медиа
|
||||
if message.photo:
|
||||
largest = max(message.photo, key=lambda p: p.file_size or 0)
|
||||
size_kb = largest.file_size / 1024 if largest.file_size else 0
|
||||
parts.append(f"({largest.width}x{largest.height}, {size_kb:.1f} KB)")
|
||||
|
||||
elif message.video:
|
||||
size_mb = message.video.file_size / (1024 * 1024) if message.video.file_size else 0
|
||||
duration = _format_duration(message.video.duration) if message.video.duration else "?"
|
||||
parts.append(f"({message.video.width}x{message.video.height}, {size_mb:.1f} MB, {duration})")
|
||||
|
||||
elif message.document:
|
||||
size_mb = message.document.file_size / (1024 * 1024) if message.document.file_size else 0
|
||||
file_name = message.document.file_name or "без имени"
|
||||
parts.append(f'("{file_name}", {size_mb:.2f} MB)')
|
||||
|
||||
elif message.audio:
|
||||
duration = _format_duration(message.audio.duration) if message.audio.duration else "?"
|
||||
title = message.audio.title or "без названия"
|
||||
parts.append(f'("{title}", {duration})')
|
||||
|
||||
elif message.voice:
|
||||
duration = _format_duration(message.voice.duration) if message.voice.duration else "?"
|
||||
parts.append(f"({duration})")
|
||||
|
||||
elif message.video_note:
|
||||
duration = _format_duration(message.video_note.duration) if message.video_note.duration else "?"
|
||||
parts.append(f"({duration})")
|
||||
|
||||
elif message.sticker:
|
||||
emoji_text = message.sticker.emoji or ""
|
||||
parts.append(f"({emoji_text})")
|
||||
|
||||
# Добавляем текст/caption
|
||||
if include_text:
|
||||
text = get_content_text(message, max_length=max_text_length)
|
||||
if text:
|
||||
parts.append(f'+ "{text}"')
|
||||
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def get_content_category(message: Message) -> ContentCategory:
|
||||
"""
|
||||
Определяет категорию контента.
|
||||
|
||||
Args:
|
||||
message: Объект сообщения
|
||||
|
||||
Returns:
|
||||
ContentCategory: Категория контента
|
||||
|
||||
Example:
|
||||
>>> get_content_category(message)
|
||||
ContentCategory.MEDIA
|
||||
"""
|
||||
content_type = message.content_type
|
||||
|
||||
# Текст
|
||||
if content_type == ContentType.TEXT:
|
||||
return ContentCategory.TEXT
|
||||
|
||||
# Медиа
|
||||
if content_type in {ContentType.PHOTO, ContentType.VIDEO, ContentType.ANIMATION, ContentType.STICKER}:
|
||||
return ContentCategory.MEDIA
|
||||
|
||||
# Файлы
|
||||
if content_type in {ContentType.DOCUMENT, ContentType.AUDIO}:
|
||||
return ContentCategory.FILE
|
||||
|
||||
# Голосовые
|
||||
if content_type in {ContentType.VOICE, ContentType.VIDEO_NOTE}:
|
||||
return ContentCategory.VOICE
|
||||
|
||||
# Локации
|
||||
if content_type in {ContentType.LOCATION, ContentType.VENUE}:
|
||||
return ContentCategory.LOCATION
|
||||
|
||||
# Интерактивные
|
||||
if content_type in {ContentType.DICE, ContentType.GAME, ContentType.POLL}:
|
||||
return ContentCategory.INTERACTION
|
||||
|
||||
# Платежи
|
||||
if content_type in {ContentType.INVOICE, ContentType.SUCCESSFUL_PAYMENT}:
|
||||
return ContentCategory.PAYMENT
|
||||
|
||||
# Служебные
|
||||
if content_type in {
|
||||
ContentType.NEW_CHAT_MEMBERS,
|
||||
ContentType.LEFT_CHAT_MEMBER,
|
||||
ContentType.NEW_CHAT_TITLE,
|
||||
ContentType.PINNED_MESSAGE
|
||||
}:
|
||||
return ContentCategory.SERVICE
|
||||
|
||||
return ContentCategory.UNKNOWN
|
||||
|
||||
|
||||
# ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
|
||||
|
||||
def _format_duration(seconds: int) -> str:
|
||||
"""
|
||||
Форматирует длительность в читаемый вид.
|
||||
|
||||
Args:
|
||||
seconds: Длительность в секундах
|
||||
|
||||
Returns:
|
||||
str: Отформатированная строка (MM:SS или HH:MM:SS)
|
||||
|
||||
Example:
|
||||
>>> _format_duration(90)
|
||||
'1:30'
|
||||
>>> _format_duration(3661)
|
||||
'1:01:01'
|
||||
"""
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
secs = seconds % 60
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def is_private_chat(message: Message) -> bool:
|
||||
"""Проверяет, является ли чат личным"""
|
||||
return message.chat.type == ChatType.PRIVATE
|
||||
|
||||
|
||||
def is_group_chat(message: Message) -> bool:
|
||||
"""Проверяет, является ли чат группой"""
|
||||
return message.chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}
|
||||
|
||||
|
||||
def is_channel(message: Message) -> bool:
|
||||
"""Проверяет, является ли чат каналом"""
|
||||
return message.chat.type == ChatType.CHANNEL
|
||||
|
||||
|
||||
# Алиасы для обратной совместимости
|
||||
type_msg = get_content_type
|
||||
type_chat = get_chat_type
|
||||
409
bot/utils/usernames.py
Normal file
409
bot/utils/usernames.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Утилиты для работы с информацией о пользователях
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
from enum import Enum
|
||||
|
||||
from aiogram.types import Message, CallbackQuery, User, InlineQuery, ChatMemberUpdated
|
||||
|
||||
__all__ = (
|
||||
'get_user_display_name',
|
||||
'get_user_mention',
|
||||
'get_user_id',
|
||||
'username',
|
||||
'format_user',
|
||||
'UserFormat',
|
||||
'is_bot',
|
||||
'has_username',
|
||||
'is_premium',
|
||||
'get_language_code',
|
||||
'compare_users',
|
||||
'get_user_info_dict'
|
||||
)
|
||||
|
||||
|
||||
class UserFormat(str, Enum):
|
||||
"""Форматы отображения пользователя"""
|
||||
USERNAME = 'username' # @username или @id123
|
||||
FULL_NAME = 'full_name' # Имя Фамилия
|
||||
MENTION = 'mention' # HTML mention
|
||||
MENTION_MARKDOWN = 'markdown' # Markdown mention
|
||||
FIRST_NAME = 'first_name' # Только имя
|
||||
ID_ONLY = 'id' # Только ID
|
||||
DETAILED = 'detailed' # @username (Имя Фамилия, ID: 123)
|
||||
|
||||
|
||||
# Тип для всех событий с пользователем
|
||||
EventType = Union[Message, CallbackQuery, InlineQuery, ChatMemberUpdated]
|
||||
|
||||
|
||||
def _extract_user(event: EventType) -> Optional[User]:
|
||||
"""
|
||||
Извлекает объект User из события.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
User или None
|
||||
"""
|
||||
if isinstance(event, (Message, CallbackQuery, InlineQuery)):
|
||||
return event.from_user
|
||||
elif isinstance(event, ChatMemberUpdated):
|
||||
return event.from_user or event.new_chat_member.user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_display_name(
|
||||
event: EventType,
|
||||
default: str = "Unknown User"
|
||||
) -> str:
|
||||
"""
|
||||
Возвращает отображаемое имя пользователя (Full Name).
|
||||
|
||||
Args:
|
||||
event: Объект события (Message, CallbackQuery, и т.д.)
|
||||
default: Значение по умолчанию если пользователь не найден
|
||||
|
||||
Returns:
|
||||
str: Полное имя пользователя
|
||||
|
||||
Example:
|
||||
>> get_user_display_name(message)
|
||||
'John Doe'
|
||||
>> get_user_display_name(message)
|
||||
'John' # Если нет фамилии
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
|
||||
if not user:
|
||||
return default
|
||||
|
||||
# Полное имя (приоритет)
|
||||
if user.full_name:
|
||||
return user.full_name
|
||||
|
||||
# Только имя
|
||||
if user.first_name:
|
||||
return user.first_name
|
||||
|
||||
# Username как запасной вариант
|
||||
if user.username:
|
||||
return f"@{user.username}"
|
||||
|
||||
# ID как последний вариант
|
||||
return f"User {user.id}"
|
||||
|
||||
|
||||
def get_user_mention(
|
||||
event: EventType,
|
||||
parse_mode: str = 'HTML',
|
||||
show_username: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Возвращает упоминание пользователя (кликабельное).
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
parse_mode: Режим парсинга ('HTML' или 'Markdown')
|
||||
show_username: Показывать username вместо имени
|
||||
|
||||
Returns:
|
||||
str: HTML/Markdown упоминание
|
||||
|
||||
Example:
|
||||
>> get_user_mention(message)
|
||||
'<a href="tg://user?id=123456789">John Doe</a>'
|
||||
|
||||
>> get_user_mention(message, parse_mode='Markdown')
|
||||
'[John Doe](tg://user?id=123456789)'
|
||||
|
||||
>> get_user_mention(message, show_username=True)
|
||||
'<a href="tg://user?id=123456789">@johndoe</a>'
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
|
||||
if not user:
|
||||
return "Unknown User"
|
||||
|
||||
# Определяем текст для отображения
|
||||
if show_username and user.username:
|
||||
display_text = f"@{user.username}"
|
||||
else:
|
||||
display_text = user.full_name or user.first_name or f"User {user.id}"
|
||||
|
||||
# Формируем ссылку
|
||||
user_link = f"tg://user?id={user.id}"
|
||||
|
||||
if parse_mode.upper() == 'HTML':
|
||||
return f'<a href="{user_link}">{display_text}</a>'
|
||||
elif parse_mode.upper() in ('MARKDOWN', 'MARKDOWNV2'):
|
||||
# Экранируем специальные символы для Markdown
|
||||
display_text = display_text.replace('[', '\\[').replace(']', '\\]')
|
||||
return f'[{display_text}]({user_link})'
|
||||
else:
|
||||
return display_text
|
||||
|
||||
|
||||
def get_user_id(event: EventType) -> Optional[int]:
|
||||
"""
|
||||
Возвращает ID пользователя.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
int или None: ID пользователя
|
||||
|
||||
Example:
|
||||
>> get_user_id(message)
|
||||
123456789
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
return user.id if user else None
|
||||
|
||||
|
||||
def username(
|
||||
event: EventType,
|
||||
with_at: bool = True,
|
||||
fallback_to_id: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Возвращает username пользователя или ID если username отсутствует.
|
||||
|
||||
Это основная функция для получения идентификатора пользователя
|
||||
в формате @username или @id123.
|
||||
|
||||
Args:
|
||||
event: Объект события (Message, CallbackQuery, и т.д.)
|
||||
with_at: Добавлять @ в начало
|
||||
fallback_to_id: Использовать ID если нет username
|
||||
|
||||
Returns:
|
||||
str: Username или ID пользователя
|
||||
|
||||
Raises:
|
||||
ValueError: Если информация о пользователе отсутствует
|
||||
|
||||
Example:
|
||||
>> username(message)
|
||||
'@johndoe'
|
||||
|
||||
>> username(message) # Нет username
|
||||
'@123456789'
|
||||
|
||||
>> username(message, with_at=False)
|
||||
'johndoe'
|
||||
|
||||
>> username(message, fallback_to_id=False)
|
||||
'' # Если нет username
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
|
||||
if not user:
|
||||
raise ValueError("Информация о пользователе отсутствует в событии")
|
||||
|
||||
# Если есть username
|
||||
if user.username:
|
||||
return f"@{user.username}" if with_at else user.username
|
||||
|
||||
# Fallback на ID
|
||||
if fallback_to_id:
|
||||
return f"@{user.id}" if with_at else str(user.id)
|
||||
|
||||
# Если ничего нет
|
||||
return ""
|
||||
|
||||
|
||||
def format_user(
|
||||
event: EventType,
|
||||
format_type: UserFormat = UserFormat.USERNAME,
|
||||
default: str = "@System"
|
||||
) -> str:
|
||||
"""
|
||||
Универсальная функция форматирования пользователя.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
format_type: Тип форматирования (из enum UserFormat)
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
str: Отформатированная информация о пользователе
|
||||
|
||||
Example:
|
||||
>> format_user(message, UserFormat.USERNAME)
|
||||
'@johndoe'
|
||||
|
||||
>> format_user(message, UserFormat.FULL_NAME)
|
||||
'John Doe'
|
||||
|
||||
>> format_user(message, UserFormat.MENTION)
|
||||
'<a href="tg://user?id=123">John Doe</a>'
|
||||
|
||||
>> format_user(message, UserFormat.DETAILED)
|
||||
'@johndoe (John Doe, ID: 123456789)'
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
|
||||
if not user:
|
||||
return default
|
||||
|
||||
# USERNAME: @username или @id
|
||||
if format_type == UserFormat.USERNAME:
|
||||
if user.username:
|
||||
return f"@{user.username}"
|
||||
return f"@{user.id}"
|
||||
|
||||
# FULL_NAME: Имя Фамилия
|
||||
elif format_type == UserFormat.FULL_NAME:
|
||||
return user.full_name or user.first_name or f"User {user.id}"
|
||||
|
||||
# MENTION: HTML упоминание
|
||||
elif format_type == UserFormat.MENTION:
|
||||
display = user.full_name or user.first_name or f"User {user.id}"
|
||||
return f'<a href="tg://user?id={user.id}">{display}</a>'
|
||||
|
||||
# MENTION_MARKDOWN: Markdown упоминание
|
||||
elif format_type == UserFormat.MENTION_MARKDOWN:
|
||||
display = user.full_name or user.first_name or f"User {user.id}"
|
||||
display = display.replace('[', '\\[').replace(']', '\\]')
|
||||
return f'[{display}](tg://user?id={user.id})'
|
||||
|
||||
# FIRST_NAME: Только имя
|
||||
elif format_type == UserFormat.FIRST_NAME:
|
||||
return user.first_name or f"User {user.id}"
|
||||
|
||||
# ID_ONLY: Только ID
|
||||
elif format_type == UserFormat.ID_ONLY:
|
||||
return str(user.id)
|
||||
|
||||
# DETAILED: Подробная информация
|
||||
elif format_type == UserFormat.DETAILED:
|
||||
parts = []
|
||||
|
||||
# Username
|
||||
if user.username:
|
||||
parts.append(f"@{user.username}")
|
||||
|
||||
# Full name
|
||||
if user.full_name:
|
||||
parts.append(f"({user.full_name}")
|
||||
elif user.first_name:
|
||||
parts.append(f"({user.first_name}")
|
||||
|
||||
# ID
|
||||
parts.append(f"ID: {user.id})")
|
||||
|
||||
return ' '.join(parts) if parts else f"User {user.id}"
|
||||
|
||||
# По умолчанию
|
||||
return default
|
||||
|
||||
|
||||
# ================= ДОПОЛНИТЕЛЬНЫЕ УТИЛИТЫ =================
|
||||
|
||||
def is_bot(event: EventType) -> bool:
|
||||
"""
|
||||
Проверяет, является ли пользователь ботом.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
bool: True если бот
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
return user.is_bot if user else False
|
||||
|
||||
|
||||
def has_username(event: EventType) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя username.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
bool: True если есть username
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
return bool(user and user.username)
|
||||
|
||||
|
||||
def is_premium(event: EventType) -> bool:
|
||||
"""
|
||||
Проверяет, есть ли у пользователя Telegram Premium.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
bool: True если Premium
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
return user.is_premium if user else False
|
||||
|
||||
|
||||
def get_language_code(event: EventType) -> Optional[str]:
|
||||
"""
|
||||
Возвращает код языка пользователя.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
Optional[str]: Код языка ('ru', 'en', и т.д.)
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
return user.language_code if user else None
|
||||
|
||||
|
||||
def compare_users(event1: EventType, event2: EventType) -> bool:
|
||||
"""
|
||||
Сравнивает двух пользователей по ID.
|
||||
|
||||
Args:
|
||||
event1: Первое событие
|
||||
event2: Второе событие
|
||||
|
||||
Returns:
|
||||
bool: True если это один и тот же пользователь
|
||||
"""
|
||||
user1 = _extract_user(event1)
|
||||
user2 = _extract_user(event2)
|
||||
|
||||
if not user1 or not user2:
|
||||
return False
|
||||
|
||||
return user1.id == user2.id
|
||||
|
||||
|
||||
def get_user_info_dict(event: EventType) -> dict:
|
||||
"""
|
||||
Возвращает всю информацию о пользователе в виде словаря.
|
||||
|
||||
Args:
|
||||
event: Объект события
|
||||
|
||||
Returns:
|
||||
dict: Словарь с информацией о пользователе
|
||||
"""
|
||||
user = _extract_user(event)
|
||||
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'full_name': user.full_name,
|
||||
'is_bot': user.is_bot,
|
||||
'is_premium': user.is_premium,
|
||||
'language_code': user.language_code,
|
||||
'mention': get_user_mention(event),
|
||||
'display_name': get_user_display_name(event)
|
||||
}
|
||||
3
configs/__init__.py
Normal file
3
configs/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cmd_alias_list import *
|
||||
from .config import *
|
||||
from .mapping import *
|
||||
363
configs/cmd_alias_list.py
Normal file
363
configs/cmd_alias_list.py
Normal file
@@ -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", "лог", # сокращения
|
||||
],
|
||||
}
|
||||
224
configs/config.py
Normal file
224
configs/config.py
Normal file
@@ -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',
|
||||
)
|
||||
163
configs/mapping.py
Normal file
163
configs/mapping.py
Normal file
@@ -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': 'в',
|
||||
}
|
||||
39
database/__init__.py
Normal file
39
database/__init__.py
Normal file
@@ -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 *
|
||||
115
database/database.py
Normal file
115
database/database.py
Normal file
@@ -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
|
||||
582
database/manager.py
Normal file
582
database/manager.py
Normal file
@@ -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
|
||||
51
database/migrate.py
Normal file
51
database/migrate.py
Normal file
@@ -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())
|
||||
254
database/models.py
Normal file
254
database/models.py
Normal file
@@ -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"<BanWord(word='{self.word}', type={self.type})>"
|
||||
|
||||
|
||||
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"<TempBanWord(word='{self.word}', expires={self.expires_at})>"
|
||||
|
||||
|
||||
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"<WhitelistWord(word='{self.word}')>"
|
||||
|
||||
|
||||
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"<Admin(user_id={self.user_id})>"
|
||||
|
||||
|
||||
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"<Setting(key='{self.key}', value='{self.value}')>"
|
||||
|
||||
|
||||
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"<SpamStat(user_id={self.user_id}, word='{self.matched_word}')>"
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
798
database/repository.py
Normal file
798
database/repository.py
Normal file
@@ -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 {}
|
||||
BIN
locales/en/LC_MESSAGES/bot.mo
Normal file
BIN
locales/en/LC_MESSAGES/bot.mo
Normal file
Binary file not shown.
58
locales/en/LC_MESSAGES/bot.po
Normal file
58
locales/en/LC_MESSAGES/bot.po
Normal file
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
|
||||
"Language: en\n"
|
||||
"Language-Team: en <LL@li.org>\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 ""
|
||||
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
|
||||
"\n"
|
||||
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
|
||||
"историй! \n"
|
||||
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
|
||||
"интереснее! \n"
|
||||
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
|
||||
"клавиатуре!\n"
|
||||
"\n"
|
||||
"Интересный факт:\n"
|
||||
"<blockquote>{fact}</blockquote>\n"
|
||||
msgstr ""
|
||||
|
||||
56
locales/messages.pot
Normal file
56
locales/messages.pot
Normal file
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\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 ""
|
||||
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
|
||||
"\n"
|
||||
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
|
||||
"историй! \n"
|
||||
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
|
||||
"интереснее! \n"
|
||||
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
|
||||
"клавиатуре!\n"
|
||||
"\n"
|
||||
"Интересный факт:\n"
|
||||
"<blockquote>{fact}</blockquote>\n"
|
||||
msgstr ""
|
||||
|
||||
BIN
locales/ru/LC_MESSAGES/bot.mo
Normal file
BIN
locales/ru/LC_MESSAGES/bot.mo
Normal file
Binary file not shown.
59
locales/ru/LC_MESSAGES/bot.po
Normal file
59
locales/ru/LC_MESSAGES/bot.po
Normal file
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
|
||||
"Language: ru\n"
|
||||
"Language-Team: ru <LL@li.org>\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 ""
|
||||
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
|
||||
"\n"
|
||||
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
|
||||
"историй! \n"
|
||||
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
|
||||
"интереснее! \n"
|
||||
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
|
||||
"клавиатуре!\n"
|
||||
"\n"
|
||||
"Интересный факт:\n"
|
||||
"<blockquote>{fact}</blockquote>\n"
|
||||
msgstr ""
|
||||
|
||||
BIN
locales/uk/LC_MESSAGES/bot.mo
Normal file
BIN
locales/uk/LC_MESSAGES/bot.mo
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user