diff --git a/.dockerignore b/.dockerignore index 2d1fc85..da0c729 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,37 +1,97 @@ +# Python __pycache__/ *.py[cod] -*.pyo -*.pyd +*$py.class *.so -*.egg-info/ +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ .eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments .env .venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE .vscode/ .idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git .git/ .gitignore -# Byte-compiled / optimized / DLL files -**/__pycache__/ -**/*.pyc -**/*.pyo -**/*.pyd +# Logs +logs/*.log -# Local settings -settings_example.ini - -# Databases and runtime files +# Database *.db *.db-shm *.db-wal -logs/ -# Tests and artifacts -.coverage +# Tests +test_*.py .pytest_cache/ -htmlcov/ -**/tests/ -# Stickers and large assets (if not needed at runtime) +# Documentation +*.md +docs/ + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Development files +Makefile +start_docker.sh +*.sh + +# Stickers and media Stick/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Backup files +*.bak +*.backup + +# Environment files +.env* +!.env.example + +# Monitoring configs (will be mounted) +prometheus.yml +grafana/ diff --git a/.gitignore b/.gitignore index 019b7d1..060656b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,20 @@ +# Database files /database/tg-bot-database.db /database/tg-bot-database.db-shm +/database/tg-bot-database.db-wm /database/tg-bot-database.db-wal /database/test.db /database/test.db-shm /database/test.db-wal -/settings.ini +/database/test_auto_unban.db +/database/test_auto_unban.db-shm +/database/test_auto_unban.db-wal + /myenv/ /venv/ -/.idea/ +/.venv/ + +# Logs /logs/*.log # Testing and coverage files @@ -29,6 +36,7 @@ test.db # IDE and editor files .vscode/ +.idea/ *.swp *.swo *~ @@ -41,4 +49,43 @@ test.db .Trashes ehthumbs.db Thumbs.db + +# Documentation files PERFORMANCE_IMPROVEMENTS.md + +# PID files +*.pid +helper_bot.pid +voice_bot.pid + +# Docker and build artifacts +*.tar.gz +prometheus-*/ +node_modules/ + +# Environment files +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp +*.log +*.pid + +# Python cache +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.cache/ +.mypy_cache/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..1635d0f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.6 diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/CHANGES_SUMMARY.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8a580d1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Use a lightweight Python image -FROM python:3.11-slim - -# Prevent Python from writing .pyc files and enable unbuffered logs -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -# Install system dependencies (if required by Python packages) -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Create non-root user -RUN useradd -m appuser \ - && chown -R appuser:appuser /app - -# Install Python dependencies first for better layer caching -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# Copy project files -COPY . . - -# Ensure runtime directories exist and are writable -RUN mkdir -p logs database \ - && chown -R appuser:appuser /app - -# Switch to non-root user -USER appuser - -# Run the bot -CMD ["python", "run_helper.py"] diff --git a/Dockerfile.bot b/Dockerfile.bot new file mode 100644 index 0000000..a4c9aba --- /dev/null +++ b/Dockerfile.bot @@ -0,0 +1,64 @@ +# Multi-stage build for production +FROM python:3.9-slim as builder + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy and install requirements +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Production stage +FROM python:3.9-slim + +# Set security options +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install runtime dependencies only +RUN apt-get update && apt-get upgrade -y && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create non-root user with fixed UID +RUN groupadd -g 1001 deploy && useradd -u 1001 -g deploy deploy + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN chown -R deploy:deploy /opt/venv + +# Create app directory and set permissions +WORKDIR /app +RUN mkdir -p /app/database /app/logs && \ + chown -R deploy:deploy /app + +# Copy application code +COPY --chown=deploy:deploy . . + +# Switch to non-root user +USER deploy + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Expose metrics port +EXPOSE 8000 + +# Graceful shutdown +STOPSIGNAL SIGTERM + +# Run application +CMD ["python", "run_helper.py"] diff --git a/Makefile b/Makefile index bf4e8f7..3b9526d 100644 --- a/Makefile +++ b/Makefile @@ -1,73 +1,121 @@ -.PHONY: help test test-db test-coverage test-html clean install +.PHONY: help build up down logs clean restart status deploy migrate backup -# Default target -help: - @echo "Available commands:" - @echo " install - Install dependencies" - @echo " test - Run all tests" - @echo " test-db - Run database tests only" - @echo " test-bot - Run bot startup and handler tests only" - @echo " test-media - Run media handler tests only" - @echo " test-errors - Run error handling tests only" - @echo " test-utils - Run utility functions tests only" - @echo " test-keyboards - Run keyboard and filter tests only" - @echo " test-coverage - Run tests with coverage report (helper_bot + database)" - @echo " test-html - Run tests and generate HTML coverage report" - @echo " clean - Clean up generated files" - @echo " coverage - Show coverage report only" +help: ## Показать справку + @echo "🐍 Telegram Bot - Доступные команды (Production Ready):" + @echo "" + @echo "🔧 Основные команды:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "📊 Мониторинг:" + @echo " Prometheus: http://localhost:9090" + @echo " Grafana: http://localhost:3000 (admin/admin)" + @echo " Bot Health: http://localhost:8000/health" -# Install dependencies -install: - python3 -m pip install -r requirements.txt - python3 -m pip install pytest-cov +build: ## Собрать все контейнеры + docker-compose build -# Run all tests -test: - python3 -m pytest tests/ -v +up: ## Запустить все сервисы + docker-compose up -d -# Run database tests only -test-db: - python3 -m pytest tests/test_db.py -v +down: ## Остановить все сервисы + docker-compose down -# Run bot tests only -test-bot: - python3 -m pytest tests/test_bot.py -v +logs: ## Показать логи всех сервисов + docker-compose logs -f -# Run media handler tests only -test-media: - python3 -m pytest tests/test_media_handlers.py -v +logs-bot: ## Показать логи бота + docker-compose logs -f telegram-bot -# Run error handling tests only -test-errors: - python3 -m pytest tests/test_error_handling.py -v +logs-prometheus: ## Показать логи Prometheus + docker-compose logs -f prometheus -# Run utils tests only -test-utils: - python3 -m pytest tests/test_utils.py -v +logs-grafana: ## Показать логи Grafana + docker-compose logs -f grafana -# Run keyboard and filter tests only -test-keyboards: - python3 -m pytest tests/test_keyboards_and_filters.py -v +restart: ## Перезапустить все сервисы + docker-compose down + docker-compose up -d -# Run tests with coverage -test-coverage: - python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=term +restart-bot: ## Перезапустить только бота + docker-compose restart telegram-bot -# Run tests and generate HTML coverage report -test-html: - python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=html:htmlcov --cov-report=term - @echo "HTML coverage report generated in htmlcov/index.html" +restart-prometheus: ## Перезапустить только Prometheus + docker-compose restart prometheus -# Show coverage report only -coverage: - python3 -m coverage report --include="helper_bot/*,database/*" +restart-grafana: ## Перезапустить только Grafana + docker-compose restart grafana -# Clean up generated files -clean: - rm -rf htmlcov/ - rm -f coverage.xml - rm -f .coverage - rm -f database/test.db - rm -f test.db - find . -type d -name "__pycache__" -exec rm -rf {} + - find . -type f -name "*.pyc" -delete +status: ## Показать статус контейнеров + docker-compose ps + +health: ## Проверить здоровье сервисов + @echo "🏥 Checking service health..." + @curl -f http://localhost:8000/health || echo "❌ Bot health check failed" + @curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus health check failed" + @curl -f http://localhost:3000/api/health || echo "❌ Grafana health check failed" + +check-python: ## Проверить версию Python в контейнере + @echo "🐍 Проверяю версию Python в контейнере..." + @docker exec telegram-bot python --version || echo "Контейнер не запущен" + +deploy: ## Полный деплой на продакшен + @echo "🚀 Starting production deployment..." + @chmod +x scripts/deploy.sh + @./scripts/deploy.sh + +migrate: ## Миграция с systemctl + cron на Docker + @echo "🔄 Starting migration from systemctl to Docker..." + @chmod +x scripts/migrate_from_systemctl.sh + @sudo ./scripts/migrate_from_systemctl.sh + +backup: ## Создать backup данных + @echo "💾 Creating backup..." + @mkdir -p backups + @tar -czf "backups/backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env + @echo "✅ Backup created in backups/" + +restore: ## Восстановить из backup (указать файл: make restore FILE=backup.tar.gz) + @echo "🔄 Restoring from backup..." + @if [ -z "$(FILE)" ]; then echo "❌ Please specify backup file: make restore FILE=backup.tar.gz"; exit 1; fi + @tar -xzf "backups/$(FILE)" -C . + @echo "✅ Backup restored" + +update: ## Обновить бота (pull latest code and redeploy) + @echo "📥 Pulling latest changes..." + @git pull origin main + @echo "🔨 Rebuilding and restarting..." + @make restart + +clean: ## Очистить все контейнеры и образы + docker-compose down -v --rmi all + docker system prune -f + +security-scan: ## Сканировать образы на уязвимости + @echo "🔍 Scanning Docker images for vulnerabilities..." + @docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + -v $(PWD):/workspace \ + --workdir /workspace \ + anchore/grype:latest \ + telegram-helper-bot_telegram-bot:latest || echo "⚠️ Grype not available, skipping scan" + +monitoring: ## Открыть мониторинг в браузере + @echo "📊 Opening monitoring dashboards..." + @open http://localhost:3000 || xdg-open http://localhost:3000 || echo "Please open manually: http://localhost:3000" + +start: build up ## Собрать и запустить все сервисы + @echo "🐍 Telegram Bot запущен!" + @echo "📊 Prometheus: http://localhost:9090" + @echo "📈 Grafana: http://localhost:3000 (admin/admin)" + @echo "🤖 Bot Health: http://localhost:8000/health" + @echo "📝 Логи: make logs" + +stop: down ## Остановить все сервисы + @echo "🛑 Все сервисы остановлены" + +test: ## Запустить все тесты + @echo "🧪 Запускаю все тесты..." + @docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest" + +test-coverage: ## Запустить все тесты с покрытием + @echo "🧪 Запускаю все тесты с покрытием..." + @docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=term-missing" diff --git a/database/async_db.py b/database/async_db.py new file mode 100644 index 0000000..e0bb693 --- /dev/null +++ b/database/async_db.py @@ -0,0 +1,995 @@ +import os +import aiosqlite +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from logs.custom_logger import logger + + +class AsyncBotDB: + """Асинхронный класс для работы с базой данных.""" + + def __init__(self, db_path: str): + self.db_path = os.path.abspath(db_path) + self.logger = logger + self.logger.info(f'Инициация асинхронной базы данных: {self.db_path}') + + async def _get_connection(self): + """Получение асинхронного соединения с базой данных.""" + try: + # Используем connect вместо connect с контекстным менеджером + conn = await aiosqlite.connect(self.db_path) + # Включаем поддержку внешних ключей + await conn.execute("PRAGMA foreign_keys = ON") + # Включаем WAL режим для лучшей производительности + await conn.execute("PRAGMA journal_mode = WAL") + await conn.execute("PRAGMA synchronous = NORMAL") + await conn.execute("PRAGMA cache_size = 10000") + await conn.execute("PRAGMA temp_store = MEMORY") + return conn + except Exception as e: + self.logger.error(f"Ошибка при получении асинхронного соединения: {e}") + raise + + async def create_tables(self): + """Создание таблиц в базе данных.""" + conn = None + try: + conn = await self._get_connection() + + # Таблица пользователей + await conn.execute(''' + CREATE TABLE IF NOT EXISTS our_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + first_name TEXT NOT NULL, + full_name TEXT NOT NULL, + username TEXT, + is_bot BOOLEAN DEFAULT FALSE, + language_code TEXT DEFAULT 'ru', + emoji TEXT DEFAULT '😊', + has_stickers BOOLEAN DEFAULT FALSE, + date_added TEXT NOT NULL, + date_changed TEXT NOT NULL + ) + ''') + + # Таблица черного списка + await conn.execute(''' + CREATE TABLE IF NOT EXISTS blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + user_name TEXT, + message_for_user TEXT, + date_to_unban TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Таблица сообщений пользователей + await conn.execute(''' + CREATE TABLE IF NOT EXISTS user_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_text TEXT NOT NULL, + user_id INTEGER NOT NULL, + message_id INTEGER UNIQUE NOT NULL, + date TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES our_users (user_id) + ) + ''') + + # Таблица постов из Telegram + await conn.execute(''' + CREATE TABLE IF NOT EXISTS post_from_telegram_suggest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER UNIQUE NOT NULL, + text TEXT NOT NULL, + author_id INTEGER NOT NULL, + helper_text_message_id INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES our_users (user_id) + ) + ''') + + # Таблица контента постов (создаем ПЕРЕД таблицей связей) + await conn.execute(''' + CREATE TABLE IF NOT EXISTS content_post_from_telegram ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER UNIQUE NOT NULL, + content_name TEXT NOT NULL, + content_type TEXT NOT NULL + ) + ''') + + # Таблица связи сообщений с контентом + await conn.execute(''' + CREATE TABLE IF NOT EXISTS message_link_to_content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id), + FOREIGN KEY (message_id) REFERENCES content_post_from_telegram (message_id) + ) + ''') + + # Таблица администраторов + await conn.execute(''' + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES our_users (user_id) + ) + ''') + + # Таблица миграций + await conn.execute(''' + CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER UNIQUE NOT NULL, + script_name TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Таблица аудио сообщений + await conn.execute(''' + CREATE TABLE IF NOT EXISTS audio_message_reference ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL, + author_id INTEGER NOT NULL, + date_added TEXT NOT NULL, + listen_count INTEGER DEFAULT 0, + file_id TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES our_users (user_id) + ) + ''') + + # Таблица прослушивания аудио + await conn.execute(''' + CREATE TABLE IF NOT EXISTS listen_audio_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL, + user_id INTEGER NOT NULL, + is_listen BOOLEAN DEFAULT FALSE, + FOREIGN KEY (user_id) REFERENCES our_users (user_id) + ) + ''') + + # Таблица для voice bot + await conn.execute(''' + CREATE TABLE IF NOT EXISTS audio_moderate ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER UNIQUE NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES our_users (user_id) + ) + ''') + + await conn.commit() + self.logger.info("Таблицы успешно созданы") + + except Exception as e: + self.logger.error(f"Ошибка при создании таблиц: {e}") + raise + finally: + if conn: + await conn.close() + + async def user_exists(self, user_id: int) -> bool: + """Проверка существования пользователя.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute("SELECT 1 FROM our_users WHERE user_id = ?", (user_id,)) as cursor: + result = await cursor.fetchone() + return bool(result) + except Exception as e: + self.logger.error(f"Ошибка при проверке существования пользователя: {e}") + raise + finally: + if conn: + await conn.close() + + async def add_new_user(self, user_id: int, first_name: str, full_name: str, username: str = None, + is_bot: bool = False, language_code: str = "ru", emoji: str = "😊"): + """Добавление нового пользователя.""" + conn = None + try: + date_added = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + date_changed = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + + conn = await self._get_connection() + await conn.execute( + "INSERT INTO our_users (user_id, first_name, full_name, username, is_bot, " + "language_code, emoji, date_added, date_changed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (user_id, first_name, full_name, username, is_bot, language_code, emoji, date_added, date_changed) + ) + await conn.commit() + self.logger.info(f"Новый пользователь добавлен: {user_id}") + except Exception as e: + self.logger.error(f"Ошибка при добавлении пользователя: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]: + """Получение информации о пользователе.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?", + (user_id,) + ) as cursor: + result = await cursor.fetchone() + if result: + return { + 'username': result[0], + 'full_name': result[1], + 'has_stickers': bool(result[2]) if result[2] is not None else False, + 'emoji': result[3] + } + return None + except Exception as e: + self.logger.error(f"Ошибка при получении информации о пользователе: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_user_date(self, user_id: int): + """Обновление даты последнего изменения пользователя.""" + conn = None + try: + date_changed = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + conn = await self._get_connection() + await conn.execute( + "UPDATE our_users SET date_changed = ? WHERE user_id = ?", + (date_changed, user_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении даты пользователя: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_user_info(self, user_id: int, username: str = None, full_name: str = None): + """Обновление информации о пользователе.""" + conn = None + try: + conn = await self._get_connection() + if username and full_name: + await conn.execute( + "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?", + (username, full_name, user_id) + ) + elif username: + await conn.execute( + "UPDATE our_users SET username = ? WHERE user_id = ?", + (username, user_id) + ) + elif full_name: + await conn.execute( + "UPDATE our_users SET full_name = ? WHERE user_id = ?", + (full_name, user_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении информации о пользователе: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_user_emoji(self, user_id: int, emoji: str): + """Обновление эмодзи пользователя.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "UPDATE our_users SET emoji = ? WHERE user_id = ?", + (emoji, user_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении эмодзи: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_user_emoji(self, user_id: int) -> Optional[str]: + """Получение эмодзи пользователя.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT emoji FROM our_users WHERE user_id = ?", (user_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении эмодзи: {e}") + raise + finally: + if conn: + await conn.close() + + async def check_emoji_exists(self, emoji: str) -> bool: + """Проверка существования эмодзи.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT 1 FROM our_users WHERE emoji = ?", (emoji,) + ) as cursor: + result = await cursor.fetchone() + return bool(result) + except Exception as e: + self.logger.error(f"Ошибка при проверке эмодзи: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_stickers_info(self, user_id: int): + """Обновление информации о стикерах.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?", + (user_id,) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении информации о стикерах: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_stickers_info(self, user_id: int) -> bool: + """Получение информации о стикерах.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT has_stickers FROM our_users WHERE user_id = ?", (user_id,) + ) as cursor: + result = await cursor.fetchone() + return bool(result[0]) if result and result[0] is not None else False + except Exception as e: + self.logger.error(f"Ошибка при получении информации о стикерах: {e}") + return False + finally: + if conn: + await conn.close() + + async def add_message(self, message_text: str, user_id: int, message_id: int): + """Добавление сообщения пользователя.""" + conn = None + try: + date = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + conn = await self._get_connection() + await conn.execute( + "INSERT INTO user_messages (message_text, user_id, message_id, date) VALUES (?, ?, ?, ?)", + (message_text, user_id, message_id, date) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении сообщения: {e}") + raise + finally: + if conn: + await conn.close() + + async def add_post(self, message_id: int, text: str, author_id: int): + """Добавление поста.""" + conn = None + try: + created_at = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + conn = await self._get_connection() + await conn.execute( + "INSERT INTO post_from_telegram_suggest (message_id, text, author_id, created_at) VALUES (?, ?, ?, ?)", + (message_id, text, author_id, created_at) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении поста: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_helper_message(self, message_id: int, helper_message_id: int): + """Обновление helper сообщения.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?", + (helper_message_id, message_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении helper сообщения: {e}") + raise + finally: + if conn: + await conn.close() + + async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str): + """Добавление контента поста.""" + conn = None + try: + conn = await self._get_connection() + # Сначала добавляем связь + await conn.execute( + "INSERT INTO message_link_to_content (post_id, message_id) VALUES (?, ?)", + (post_id, message_id) + ) + # Затем добавляем контент + await conn.execute( + "INSERT INTO content_post_from_telegram (message_id, content_name, content_type) VALUES (?, ?, ?)", + (message_id, content_name, content_type) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении контента поста: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_post_content(self, last_post_id: int) -> List: + """Получение контента поста.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute(""" + SELECT cpft.content_name, cpft.content_type + FROM post_from_telegram_suggest pft + JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id + JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id + WHERE pft.helper_text_message_id = ? + """, (last_post_id,)) as cursor: + return await cursor.fetchall() + except Exception as e: + self.logger.error(f"Ошибка при получении контента поста: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_post_text(self, last_post_id: int) -> Optional[str]: + """Получение текста поста.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?", + (last_post_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении текста поста: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_post_ids(self, last_post_id: int) -> List: + """Получение ID постов.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute(""" + SELECT mltc.message_id + FROM post_from_telegram_suggest pft + JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id + WHERE pft.helper_text_message_id = ? + """, (last_post_id,)) as cursor: + result = await cursor.fetchall() + return [row[0] for row in result] + except Exception as e: + self.logger.error(f"Ошибка при получении ID постов: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_author_id_by_message(self, message_id: int) -> Optional[int]: + """Получение ID автора по message_id.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?", + (message_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении ID автора: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_author_id_by_helper_message(self, helper_message_id: int) -> Optional[int]: + """Получение ID автора по helper_message_id.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?", + (helper_message_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении ID автора по helper сообщению: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_last_users(self, limit: int = 30) -> List: + """Получение последних пользователей.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?", + (limit,) + ) as cursor: + return await cursor.fetchall() + except Exception as e: + self.logger.error(f"Ошибка при получении последних пользователей: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_user_by_message_id(self, message_id: int) -> Optional[int]: + """Получение пользователя по message_id.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT user_id FROM user_messages WHERE message_id = ?", + (message_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении пользователя по message_id: {e}") + raise + finally: + if conn: + await conn.close() + + # Методы для работы с черным списком + async def add_to_blacklist(self, user_id: int, user_name: str = None, + message_for_user: str = None, date_to_unban: str = None): + """Добавление пользователя в черный список.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "INSERT INTO blacklist (user_id, user_name, message_for_user, date_to_unban) VALUES (?, ?, ?, ?)", + (user_id, user_name, message_for_user, date_to_unban) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении в черный список: {e}") + raise + finally: + if conn: + await conn.close() + + async def remove_from_blacklist(self, user_id: int) -> bool: + """Удаление пользователя из черного списка.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute("DELETE FROM blacklist WHERE user_id = ?", (user_id,)) + await conn.commit() + return True + except Exception as e: + self.logger.error(f"Ошибка при удалении из черного списка: {e}") + return False + finally: + if conn: + await conn.close() + + async def check_blacklist(self, user_id: int) -> bool: + """Проверка пользователя в черном списке.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT 1 FROM blacklist WHERE user_id = ?", (user_id,) + ) as cursor: + result = await cursor.fetchone() + return bool(result) + except Exception as e: + self.logger.error(f"Ошибка при проверке черного списка: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List: + """Получение пользователей из черного списка.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT user_name, user_id, message_for_user, date_to_unban FROM blacklist LIMIT ?, ?", + (offset, limit) + ) as cursor: + return await cursor.fetchall() + except Exception as e: + self.logger.error(f"Ошибка при получении пользователей из черного списка: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_blacklist_count(self) -> int: + """Получение количества пользователей в черном списке.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute("SELECT COUNT(*) FROM blacklist") as cursor: + result = await cursor.fetchone() + return result[0] if result else 0 + except Exception as e: + self.logger.error(f"Ошибка при получении количества пользователей в черном списке: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_users_for_unban_today(self, date_to_unban: str) -> List: + """Получение пользователей для разблокировки сегодня.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT user_id, user_name FROM blacklist WHERE date_to_unban = ?", + (date_to_unban,) + ) as cursor: + return await cursor.fetchall() + except Exception as e: + self.logger.error(f"Ошибка при получении пользователей для разблокировки: {e}") + raise + finally: + if conn: + await conn.close() + + # Методы для работы с администраторами + async def add_admin(self, user_id: int, role: str = "admin"): + """Добавление администратора.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "INSERT INTO admins (user_id, role) VALUES (?, ?)", + (user_id, role) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении администратора: {e}") + raise + finally: + if conn: + await conn.close() + + async def remove_admin(self, user_id: int): + """Удаление администратора.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute("DELETE FROM admins WHERE user_id = ?", (user_id,)) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при удалении администратора: {e}") + raise + finally: + if conn: + await conn.close() + + async def is_admin(self, user_id: int) -> bool: + """Проверка, является ли пользователь администратором.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT 1 FROM admins WHERE user_id = ?", (user_id,) + ) as cursor: + result = await cursor.fetchone() + return bool(result) + except Exception as e: + self.logger.error(f"Ошибка при проверке прав администратора: {e}") + return False + finally: + if conn: + await conn.close() + + # Методы для работы с аудио + async def add_audio_record(self, file_name: str, author_id: int, file_id: str): + """Добавление аудио записи.""" + conn = None + try: + date_added = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + conn = await self._get_connection() + await conn.execute( + "INSERT INTO audio_message_reference (file_name, author_id, date_added, file_id) VALUES (?, ?, ?, ?)", + (file_name, author_id, date_added, file_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при добавлении аудио записи: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_last_audio_date(self) -> Optional[str]: + """Получение даты последнего аудио.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1" + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении даты последнего аудио: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_user_audio_records(self, user_id: int) -> bool: + """Проверка наличия аудио записей у пользователя.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT 1 FROM audio_message_reference WHERE author_id = ? LIMIT 1", + (user_id,) + ) as cursor: + result = await cursor.fetchone() + return bool(result) + except Exception as e: + self.logger.error(f"Ошибка при проверке аудио записей пользователя: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_audio_file_id(self, user_id: int) -> Optional[str]: + """Получение file_id последнего аудио пользователя.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT file_id FROM audio_message_reference WHERE author_id = ? ORDER BY date_added DESC LIMIT 1", + (user_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении file_id аудио: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_audio_file_name(self, user_id: int) -> Optional[str]: + """Получение имени файла последнего аудио пользователя.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT file_name FROM audio_message_reference WHERE author_id = ? ORDER BY date_added DESC LIMIT 1", + (user_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении имени файла аудио: {e}") + raise + finally: + if conn: + await conn.close() + + async def check_audio_listened(self, user_id: int) -> List[str]: + """Проверка прослушанных аудио пользователем.""" + conn = None + try: + conn = await self._get_connection() + # Получаем все аудио файлы + async with conn.execute( + "SELECT file_name FROM audio_message_reference WHERE author_id != ?", + (user_id,) + ) as cursor: + all_audio = await cursor.fetchall() + + # Получаем прослушанные пользователем + async with conn.execute(""" + SELECT l.file_name + FROM audio_message_reference a + LEFT JOIN listen_audio_users l ON l.file_name = a.file_name + WHERE l.user_id = ? AND l.file_name IS NOT NULL + """, (user_id,)) as cursor: + listened_audio = await cursor.fetchall() + + # Находим непрослушанные + all_audio_names = {row[0] for row in all_audio} + listened_names = {row[0] for row in listened_audio} + return list(all_audio_names - listened_names) + + except Exception as e: + self.logger.error(f"Ошибка при проверке прослушанных аудио: {e}") + raise + finally: + if conn: + await conn.close() + + async def mark_audio_listened(self, file_name: str, user_id: int): + """Отметка аудио как прослушанного.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "INSERT INTO listen_audio_users (file_name, user_id, is_listen) VALUES (?, ?, ?)", + (file_name, user_id, 1) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при отметке аудио как прослушанного: {e}") + raise + finally: + if conn: + await conn.close() + + async def clear_user_audio_listen(self, user_id: int): + """Очистка данных о прослушивании аудио пользователем.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "DELETE FROM listen_audio_users WHERE user_id = ?", + (user_id,) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при очистке данных о прослушивании: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_user_by_audio_file(self, file_name: str) -> Optional[int]: + """Получение пользователя по имени аудио файла.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT author_id FROM audio_message_reference WHERE file_name = ?", + (file_name,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении пользователя по имени файла: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_audio_date(self, file_name: str) -> Optional[str]: + """Получение даты аудио файла.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT date_added FROM audio_message_reference WHERE file_name = ?", + (file_name,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении даты аудио файла: {e}") + raise + finally: + if conn: + await conn.close() + + # Методы для voice bot + async def set_voice_bot_message(self, message_id: int, user_id: int): + """Установка связи message_id и user_id для voice bot.""" + conn = None + try: + conn = await self._get_connection() + await conn.execute( + "INSERT INTO audio_moderate (message_id, user_id) VALUES (?, ?)", + (message_id, user_id) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при установке связи для voice bot: {e}") + raise + finally: + if conn: + await conn.close() + + async def get_voice_bot_user(self, message_id: int) -> Optional[int]: + """Получение пользователя voice bot по message_id.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT user_id FROM audio_moderate WHERE message_id = ?", + (message_id,) + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else None + except Exception as e: + self.logger.error(f"Ошибка при получении пользователя voice bot: {e}") + raise + finally: + if conn: + await conn.close() + + # Методы для миграций + async def get_migration_version(self) -> int: + """Получение текущей версии миграции.""" + conn = None + try: + conn = await self._get_connection() + async with conn.execute( + "SELECT version FROM migrations ORDER BY version DESC LIMIT 1" + ) as cursor: + result = await cursor.fetchone() + return result[0] if result else 0 + except Exception as e: + self.logger.error(f"Ошибка при получении версии миграции: {e}") + raise + finally: + if conn: + await conn.close() + + async def update_migration_version(self, version: int, script_name: str): + """Обновление версии миграции.""" + conn = None + try: + created_at = datetime.now().strftime("%d-%m-%Y %H:%M:%S") + conn = await self._get_connection() + await conn.execute( + "INSERT INTO migrations (version, script_name, created_at) VALUES (?, ?, ?)", + (version, script_name, created_at) + ) + await conn.commit() + except Exception as e: + self.logger.error(f"Ошибка при обновлении версии миграции: {e}") + raise + finally: + if conn: + await conn.close() + + async def close(self): + """Закрытие соединений.""" + # Соединения закрываются в каждом методе + pass diff --git a/database/db.py b/database/db.py index d0639fc..481c4e4 100644 --- a/database/db.py +++ b/database/db.py @@ -6,14 +6,26 @@ from concurrent.futures import ThreadPoolExecutor from logs.custom_logger import logger +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors, + db_query_time +) + class BotDB: def __init__(self, current_dir, name): + print(f"DEBUG BotDB: current_dir={current_dir}, name={name}") # Формируем правильный путь к базе данных if name.startswith('database/'): + # Если имя уже содержит database/, то используем его как есть self.db_file = os.path.join(current_dir, name) else: + # Если имя не содержит database/, то добавляем его self.db_file = os.path.join(current_dir, 'database', name) + print(f"DEBUG BotDB: db_file={self.db_file}") self.conn = None self.cursor = None self.logger = logger @@ -138,6 +150,9 @@ class BotDB: finally: self.close() + @track_time("add_new_user_in_db", "database") + @track_errors("database", "add_new_user_in_db") + @db_query_time("add_new_user_in_db", "our_users", "insert") def add_new_user_in_db(self, user_id: int, first_name: str, full_name: str, username: str, is_bot: bool, language_code: str, emoji: str, date_added: str, date_changed: str): """ @@ -189,6 +204,9 @@ class BotDB: finally: self.close() + @track_time("user_exists", "database") + @track_errors("database", "user_exists") + @db_query_time("user_exists", "our_users", "select") def user_exists(self, user_id: int): """ Проверяет, существует ли пользователь в базе данных. @@ -426,6 +444,9 @@ class BotDB: finally: self.close() + @track_time("get_info_about_stickers", "database") + @track_errors("database", "get_info_about_stickers") + @db_query_time("get_info_about_stickers", "our_users", "select") def get_info_about_stickers(self, user_id: int): """ Проверяет, получил ли пользователь стикеры. @@ -459,6 +480,9 @@ class BotDB: finally: self.close() + @track_time("update_info_about_stickers", "database") + @track_errors("database", "update_info_about_stickers") + @db_query_time("update_info_about_stickers", "our_users", "update") def update_info_about_stickers(self, user_id): """ Обновляет информацию о получении стикеров пользователем. @@ -485,31 +509,6 @@ class BotDB: finally: self.close() - def get_users_blacklist(self): - """ - Возвращает список пользователей в черном списке. - - Returns: - dict: Словарь, где ключ - user_id, значение - username. - {}: Если в черном списке нет пользователей. - - Raises: - sqlite3. Error: Если произошла ошибка при выполнении запроса. - """ - self.logger.info(f"Запуск функции get_users_blacklist") - try: - self.connect() - self.cursor.execute("SELECT user_id, user_name FROM blacklist") - fetch_all = self.cursor.fetchall() - list_of_users = {user_id: username for user_id, username in fetch_all} - self.logger.info(f"Получен список пользователей в черном списке") - return list_of_users - except sqlite3.Error as error: - self.logger.error(f"Ошибка при получении списка пользователей в черном списке: {error}") - raise - finally: - self.close() - def get_users_for_unblock_today(self, date_to_unban: str): """ Возвращает список пользователей, у которых истекает срок блокировки сегодня. @@ -648,6 +647,9 @@ class BotDB: finally: self.close() + @track_time("add_new_message_in_db", "database") + @track_errors("database", "add_new_message_in_db") + @db_query_time("add_new_message_in_db", "user_messages", "insert") def add_new_message_in_db(self, message_text: str, user_id: int, message_id: int, date: str): """ Добавляет новое сообщение пользователя в базу данных. @@ -682,6 +684,9 @@ class BotDB: finally: self.close() + @track_time("get_username_and_full_name", "database") + @track_errors("database", "get_username_and_full_name") + @db_query_time("get_username_and_full_name", "our_users", "select") def get_username_and_full_name(self, user_id: int): """ Получает full_name и username пользователя по ID из базы @@ -711,6 +716,9 @@ class BotDB: finally: self.close() + @track_time("update_username_and_full_name", "database") + @track_errors("database", "update_username_and_full_name") + @db_query_time("update_username_and_full_name", "our_users", "update") def update_username_and_full_name(self, user_id: int, username: str, full_name: str): """ Обновляет full_name и username пользователя @@ -740,6 +748,9 @@ class BotDB: finally: self.close() + @track_time("update_date_for_user", "database") + @track_errors("database", "update_date_for_user") + @db_query_time("update_date_for_user", "our_users", "update") def update_date_for_user(self, date: str, user_id: int): """ #TODO: Не возвращается ошибка sqlite3. Error. Тест не перехватывает. Возвращается no such table: our_users @@ -767,6 +778,9 @@ class BotDB: finally: self.close() + @track_time("check_emoji", "database") + @track_errors("database", "check_emoji") + @db_query_time("check_emoji", "our_users", "select") def check_emoji(self, emoji: str): """ Проверяет, есть ли уже такой emoji в таблице. @@ -792,6 +806,9 @@ class BotDB: finally: self.close() + @track_time("update_emoji_for_user", "database") + @track_errors("database", "update_emoji_for_user") + @db_query_time("update_emoji_for_user", "our_users", "update") def update_emoji_for_user(self, user_id: int, emoji: str): """ Обновляет эмодзи для пользователя в базе если его ранее не было установлено @@ -817,6 +834,9 @@ class BotDB: finally: self.close() + @track_time("check_emoji_for_user", "database") + @track_errors("database", "check_emoji_for_user") + @db_query_time("check_emoji_for_user", "our_users", "select") def check_emoji_for_user(self, user_id: int): """ Проверяет, есть ли уже у пользователя назначенный emoji. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb4580c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,130 @@ +version: '3.8' + +services: + telegram-bot: + build: + context: . + dockerfile: Dockerfile.bot + container_name: telegram-bot + restart: unless-stopped + expose: + - "8000" + environment: + - PYTHONPATH=/app + - DOCKER_CONTAINER=true + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} + - METRICS_HOST=${METRICS_HOST:-0.0.0.0} + - METRICS_PORT=${METRICS_PORT:-8000} + # Telegram settings + - TELEGRAM_BOT_TOKEN=${BOT_TOKEN} + - TELEGRAM_LISTEN_BOT_TOKEN=${LISTEN_BOT_TOKEN} + - TELEGRAM_TEST_BOT_TOKEN=${TEST_BOT_TOKEN} + - TELEGRAM_PREVIEW_LINK=${PREVIEW_LINK:-false} + - TELEGRAM_MAIN_PUBLIC=${MAIN_PUBLIC} + - TELEGRAM_GROUP_FOR_POSTS=${GROUP_FOR_POSTS} + - TELEGRAM_GROUP_FOR_MESSAGE=${GROUP_FOR_MESSAGE} + - TELEGRAM_GROUP_FOR_LOGS=${GROUP_FOR_LOGS} + - TELEGRAM_IMPORTANT_LOGS=${IMPORTANT_LOGS} + - TELEGRAM_ARCHIVE=${ARCHIVE} + - TELEGRAM_TEST_GROUP=${TEST_GROUP} + # Bot settings + - SETTINGS_LOGS=${LOGS:-false} + - SETTINGS_TEST=${TEST:-false} + # Database + - DATABASE_PATH=${DATABASE_PATH:-database/tg-bot-database.db} + volumes: + - ./database:/app/database:rw + - ./logs:/app/logs:rw + - ./.env:/app/.env:ro + networks: + - bot-internal + depends_on: + - prometheus + - grafana + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + expose: + - "9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + restart: unless-stopped + networks: + - bot-internal + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + memory: 256M + cpus: '0.25' + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" # Grafana доступна извне + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=http://localhost:3000 + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/datasources:/etc/grafana/provisioning/datasources:ro + restart: unless-stopped + networks: + - bot-internal + depends_on: + - prometheus + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + memory: 256M + cpus: '0.25' + +volumes: + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + bot-internal: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 diff --git a/env.example b/env.example new file mode 100644 index 0000000..588a34f --- /dev/null +++ b/env.example @@ -0,0 +1,29 @@ +# Telegram Bot Configuration +BOT_TOKEN=your_bot_token_here +LISTEN_BOT_TOKEN=your_listen_bot_token_here +TEST_BOT_TOKEN=your_test_bot_token_here + +# Telegram Groups +MAIN_PUBLIC=@your_main_public_group +GROUP_FOR_POSTS=-1001234567890 +GROUP_FOR_MESSAGE=-1001234567890 +GROUP_FOR_LOGS=-1001234567890 +IMPORTANT_LOGS=-1001234567890 +ARCHIVE=-1001234567890 +TEST_GROUP=-1001234567890 + +# Bot Settings +PREVIEW_LINK=false +LOGS=false +TEST=false + +# Database +DATABASE_PATH=database/tg-bot-database.db + +# Monitoring +METRICS_HOST=0.0.0.0 +METRICS_PORT=8000 + +# Logging +LOG_LEVEL=INFO +LOG_RETENTION_DAYS=30 diff --git a/grafana/dashboards/dashboards.yml b/grafana/dashboards/dashboards.yml new file mode 100644 index 0000000..304cbc9 --- /dev/null +++ b/grafana/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'Telegram Bot Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/grafana/dashboards/telegram-bot-dashboard.json b/grafana/dashboards/telegram-bot-dashboard.json new file mode 100644 index 0000000..f6f6e18 --- /dev/null +++ b/grafana/dashboards/telegram-bot-dashboard.json @@ -0,0 +1,1012 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum(rate(bot_commands_total[5m]))", + "refId": "A" + } + ], + "title": "Commands per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.95, rate(method_duration_seconds_bucket[5m]))", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.99, rate(method_duration_seconds_bucket[5m]))", + "refId": "B" + } + ], + "title": "Method Response Time (P95, P99)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum(rate(errors_total[5m]))", + "refId": "A" + } + ], + "title": "Errors per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum(active_users)", + "refId": "A" + } + ], + "title": "Active Users", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.95, rate(db_query_duration_seconds_bucket[5m]))", + "refId": "A" + } + ], + "title": "Database Query Time (P95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum(rate(messages_processed_total[5m]))", + "refId": "A" + } + ], + "title": "Messages Processed per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum by(query_type) (rate(db_queries_total[5m]))", + "refId": "A" + } + ], + "title": "Database Queries by Type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "rate(db_errors_total[5m])", + "refId": "A" + } + ], + "title": "Database Errors per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum by(command) (rate(bot_commands_total[5m]))", + "refId": "A" + } + ], + "title": "Commands by Type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "sum by(status) (rate(bot_commands_total[5m]))", + "refId": "A" + } + ], + "title": "Commands by Status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 40 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "topk(5, sum by(command) (rate(bot_commands_total[5m])))", + "refId": "A" + } + ], + "title": "Top Commands", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "telegram", + "bot", + "monitoring" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Telegram Bot Dashboard", + "uid": "telegram-bot", + "version": 1, + "weekStart": "" +} diff --git a/grafana/datasources/prometheus.yml b/grafana/datasources/prometheus.yml new file mode 100644 index 0000000..86fd346 --- /dev/null +++ b/grafana/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/helper_bot/__init__.py b/helper_bot/__init__.py index e69de29..3ed7b11 100644 --- a/helper_bot/__init__.py +++ b/helper_bot/__init__.py @@ -0,0 +1 @@ +from . import server_monitor diff --git a/helper_bot/handlers/admin/__init__.py b/helper_bot/handlers/admin/__init__.py index 52fb315..29f53ec 100644 --- a/helper_bot/handlers/admin/__init__.py +++ b/helper_bot/handlers/admin/__init__.py @@ -1 +1,37 @@ -from .admin_handlers import admin_router \ No newline at end of file +from .admin_handlers import admin_router +from .dependencies import AdminAccessMiddleware, BotDB, Settings +from .services import AdminService, User, BannedUser +from .exceptions import ( + AdminError, + AdminAccessDeniedError, + UserNotFoundError, + InvalidInputError, + UserAlreadyBannedError +) +from .utils import ( + return_to_admin_menu, + handle_admin_error, + format_user_info, + format_ban_confirmation, + escape_html +) + +__all__ = [ + 'admin_router', + 'AdminAccessMiddleware', + 'BotDB', + 'Settings', + 'AdminService', + 'User', + 'BannedUser', + 'AdminError', + 'AdminAccessDeniedError', + 'UserNotFoundError', + 'InvalidInputError', + 'UserAlreadyBannedError', + 'return_to_admin_menu', + 'handle_admin_error', + 'format_user_info', + 'format_ban_confirmation', + 'escape_html' +] \ No newline at end of file diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 020c3af..3e2876c 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -1,50 +1,55 @@ -import traceback -import html - from aiogram import Router, types, F -from aiogram.filters import Command, StateFilter +from aiogram.filters import Command, StateFilter, MagicData from aiogram.fsm.context import FSMContext from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards.keyboards import get_reply_keyboard_admin, create_keyboard_with_pagination, \ - create_keyboard_for_ban_days, create_keyboard_for_approve_ban, create_keyboard_for_ban_reason -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import check_access, add_days_to_date, get_banned_users_buttons, get_banned_users_list +from helper_bot.keyboards.keyboards import ( + get_reply_keyboard_admin, + create_keyboard_with_pagination, + create_keyboard_for_ban_days, + create_keyboard_for_approve_ban, + create_keyboard_for_ban_reason +) +from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware +from helper_bot.handlers.admin.services import AdminService +from helper_bot.handlers.admin.exceptions import ( + UserAlreadyBannedError, + InvalidInputError +) +from helper_bot.handlers.admin.utils import ( + return_to_admin_menu, + handle_admin_error, + format_user_info, + format_ban_confirmation, + escape_html +) from logs.custom_logger import logger +# Создаем роутер с middleware для проверки доступа admin_router = Router() +admin_router.message.middleware(AdminAccessMiddleware()) -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] - -BotDB = bdf.get_db() +# ============================================================================ +# ХЕНДЛЕРЫ МЕНЮ +# ============================================================================ @admin_router.message( ChatTypeFilter(chat_type=["private"]), Command('admin') ) -async def admin_panel(message: types.Message, state: FSMContext): +async def admin_panel( + message: types.Message, + state: FSMContext +): + """Главное меню администратора""" try: - if check_access(message.from_user.id, BotDB): - await state.set_state("ADMIN") - logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") - markup = get_reply_keyboard_admin() - await message.answer("Добро пожаловать в админку. Выбери что хочешь:", - reply_markup=markup) - else: - await message.answer('Доступ запрещен, досвидания!') + await state.set_state("ADMIN") + logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") + markup = get_reply_keyboard_admin() + await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) except Exception as e: - logger.error(f"Ошибка при запуске админ панели: {e}") - await message.bot.send_message(IMPORTANT_LOGS, - f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}') + await handle_admin_error(message, e, state, "admin_panel") @admin_router.message( @@ -52,189 +57,30 @@ async def admin_panel(message: types.Message, state: FSMContext): StateFilter("ADMIN"), F.text == 'Бан (Список)' ) -async def get_last_users(message: types.Message): - logger.info( - f"Попытка получения списка последних пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") - list_users = BotDB.get_last_users_from_db() - keyboard = create_keyboard_with_pagination(1, len(list_users), list_users, 'ban') - await message.answer(text="Список пользователей которые последними обращались к боту", - reply_markup=keyboard) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("ADMIN"), - F.text == 'Бан по нику' -) -async def ban_by_nickname(message: types.Message, state: FSMContext): - await message.answer('Пришли мне username блокируемого пользователя') - await state.set_state('PRE_BAN') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("ADMIN"), - F.text == 'Бан по ID' -) -async def ban_by_id(message: types.Message, state: FSMContext): - await message.answer('Пришли мне ID блокируемого пользователя') - await state.set_state('PRE_BAN_ID') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("ADMIN"), - F.text == 'Тестовый бан' -) -async def ban_by_forward(message: types.Message, state: FSMContext): - await message.answer('Перешлите мне сообщение от пользователя, которого хотите заблокировать') - await state.set_state('PRE_BAN_FORWARD') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - F.text == 'Отменить' -) -async def decline_ban(message: types.Message, state: FSMContext): - current_state = await state.get_state() - await state.set_data({}) - await state.set_state("ADMIN") - logger.info(f"Отмена процедуры блокировки из состояния: {current_state}") - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN") -) -async def ban_by_nickname_step_2(message: types.Message, state: FSMContext): - logger.info( - f"Функция ban_by_nickname_2. Получен никнейм пользователя: {message.text}") - user_name = message.text - user_id = BotDB.get_user_id_by_username(user_name) - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - full_name = BotDB.get_full_name_by_id(user_id) - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(full_name)) - await message.answer( - text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\n" - f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN_ID") -) -async def ban_by_id_step_2(message: types.Message, state: FSMContext): +async def get_last_users( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Получение списка последних пользователей""" try: - user_id = int(message.text) - logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}") + logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}") + admin_service = AdminService(bot_db) + users = admin_service.get_last_users() - # Проверяем, существует ли пользователь в базе - user_info = BotDB.get_user_info_by_id(user_id) - if not user_info: - await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - return + # Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination) + users_data = [ + (user.full_name, user.username) # (full_name, username) - формат кортежей + for user in users + ] - user_name = user_info.get('username', 'Неизвестно') - full_name = user_info.get('full_name', 'Неизвестно') - - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(full_name)) + keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban') await message.answer( - text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\n" - f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - - except ValueError: - await message.answer("Пожалуйста, введите корректный числовой ID пользователя.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN_FORWARD"), - F.forward_from -) -async def ban_by_forward_step_2(message: types.Message, state: FSMContext): - """Обработчик пересланных сообщений для бана пользователя""" - try: - # Получаем информацию о пользователе из пересланного сообщения - forwarded_user = message.forward_from - - if not forwarded_user: - await message.answer("Не удалось получить информацию о пользователе из пересланного сообщения. Возможно, пользователь скрыл возможность пересылки своих сообщений.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - return - - user_id = forwarded_user.id - user_name = forwarded_user.username or "private_username" - full_name = forwarded_user.full_name or "Неизвестно" - - logger.info(f"Функция ban_by_forward_step_2. Получен пользователь из пересланного сообщения: ID={user_id}, username={user_name}, full_name={full_name}") - - # Проверяем, существует ли пользователь в базе - user_info = BotDB.get_user_info_by_id(user_id) - if not user_info: - # Если пользователя нет в базе, используем информацию из пересланного сообщения - logger.info(f"Пользователь с ID {user_id} не найден в базе данных, используем данные из пересланного сообщения") - user_name = user_name - full_name = full_name - else: - # Если пользователь есть в базе, используем данные из базы - user_name = user_info.get('username', user_name) - full_name = user_info.get('full_name', full_name) - - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(full_name)) - await message.answer( - text=f"Выбран пользователь из пересланного сообщения:\nid: {user_id}\nusername: {user_name_escaped}\n" - f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - + text="Список пользователей которые последними обращались к боту", + reply_markup=keyboard + ) except Exception as e: - logger.error(f"Ошибка при обработке пересланного сообщения: {e}") - await message.answer("Произошла ошибка при обработке пересланного сообщения.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN_FORWARD") -) -async def ban_by_forward_invalid(message: types.Message, state: FSMContext): - """Обработчик для случаев, когда сообщение не является пересланным или не содержит информацию о пользователе""" - if message.forward_from_chat: - await message.answer("Пересланное сообщение из канала или группы не содержит информацию о конкретном пользователе. Пожалуйста, перешлите сообщение из приватного чата.") - else: - await message.answer("Пожалуйста, перешлите сообщение от пользователя, которого хотите заблокировать. Обычное сообщение не подходит.") + await handle_admin_error(message, e, state, "get_last_users") @admin_router.message( @@ -242,80 +88,263 @@ async def ban_by_forward_invalid(message: types.Message, state: FSMContext): StateFilter("ADMIN"), F.text == 'Разбан (список)' ) -async def get_banned_users(message): - logger.info( - f"Попытка получения списка заблокированных пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") - message_text = get_banned_users_list(0, BotDB) - buttons_list = get_banned_users_buttons(BotDB) - if buttons_list: - k = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') - await message.answer(text=message_text, reply_markup=k) - else: - await message.answer(text="В списке забанненых пользователей никого нет") +async def get_banned_users( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Получение списка заблокированных пользователей""" + try: + logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}") + admin_service = AdminService(bot_db) + message_text, buttons_list = admin_service.get_banned_users_for_display(0) + + if buttons_list: + keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') + await message.answer(text=message_text, reply_markup=keyboard) + else: + await message.answer(text="В списке заблокированных пользователей никого нет") + except Exception as e: + await handle_admin_error(message, e, state, "get_banned_users") +# ============================================================================ +# ХЕНДЛЕРЫ ПРОЦЕССА БАНА +# ============================================================================ + @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_2") + StateFilter("ADMIN"), + F.text.in_(['Бан по нику', 'Бан по ID']) ) -async def ban_user_step_2(message: types.Message, state: FSMContext): - user_data = await state.get_data() - logger.info(f"Переход на шаг 2 бана пользователя. Словарь с данными для бана: {user_data})") - await state.update_data(message_for_user=message.text) - markup = create_keyboard_for_ban_days() - # Экранируем message.text для безопасного использования - safe_message_text = html.escape(str(message.text)) if message.text else "" - await message.answer(f"Выбрана причина: {safe_message_text}. Выбери срок бана в днях или напиши " - f"его в чат", reply_markup=markup) - await state.set_state("BAN_3") +async def start_ban_process( + message: types.Message, + state: FSMContext, + ): + """Начало процесса блокировки пользователя""" + try: + ban_type = "username" if message.text == 'Бан по нику' else "id" + await state.update_data(ban_type=ban_type) + + prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя" + await message.answer(prompt_text) + await state.set_state('AWAIT_BAN_TARGET') + except Exception as e: + await handle_admin_error(message, e, state, "start_ban_process") @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_3") + StateFilter("AWAIT_BAN_TARGET") ) -async def ban_user_step_3(message: types.Message, state: FSMContext): - logger.info(f"ban_user_step_3. Расчет даты разбана. Входные данные {message.text}") - if message.text != 'Навсегда': - count_days = int(message.text) - date_to_unban = add_days_to_date(count_days) - else: - date_to_unban = None - logger.info(f"ban_user_step_3. Расчет даты разбана. date_to_unban: {date_to_unban}") - await state.update_data(date_to_unban=date_to_unban) - user_data = await state.get_data() - markup = create_keyboard_for_approve_ban() - # Экранируем user_data для безопасного использования - safe_message_for_user = html.escape(str(user_data['message_for_user'])) if user_data.get('message_for_user') else "" - safe_date_to_unban = html.escape(str(user_data['date_to_unban'])) if user_data.get('date_to_unban') else "" - await message.answer( - f"Необходимо подтверждение:\nПользователь:{user_data['user_id']}\nПричина бана:{safe_message_for_user}\nСрок бана:{safe_date_to_unban}", - reply_markup=markup) - await state.set_state("BAN_FINAL") +async def process_ban_target( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Обработка введенного username/ID для блокировки""" + try: + user_data = await state.get_data() + ban_type = user_data.get('ban_type') + admin_service = AdminService(bot_db) + + + # Определяем пользователя + if ban_type == "username": + user = admin_service.get_user_by_username(message.text) + if not user: + await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.") + await return_to_admin_menu(message, state) + return + else: # ban_type == "id" + try: + user_id = admin_service.validate_user_input(message.text) + user = admin_service.get_user_by_id(user_id) + if not user: + await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") + await return_to_admin_menu(message, state) + return + except InvalidInputError as e: + await message.answer(str(e)) + await return_to_admin_menu(message, state) + return + + # Сохраняем данные пользователя + await state.update_data( + target_user_id=user.user_id, + target_username=user.username, + target_full_name=user.full_name + ) + + # Показываем информацию о пользователе и запрашиваем причину + user_info = format_user_info(user.user_id, user.username, user.full_name) + markup = create_keyboard_for_ban_reason() + await message.answer( + text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат", + reply_markup=markup + ) + await state.set_state('AWAIT_BAN_DETAILS') + + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_target") @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_FINAL"), + StateFilter("AWAIT_BAN_DETAILS") +) +async def process_ban_reason( + message: types.Message, + state: FSMContext + ): + """Обработка причины блокировки""" + try: + await state.update_data(ban_reason=message.text) + markup = create_keyboard_for_ban_days() + safe_reason = escape_html(message.text) + await message.answer( + f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат", + reply_markup=markup + ) + await state.set_state('AWAIT_BAN_DURATION') + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_reason") + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_BAN_DURATION") +) +async def process_ban_duration( + message: types.Message, + state: FSMContext, + ): + """Обработка срока блокировки""" + try: + user_data = await state.get_data() + + # Определяем срок блокировки + if message.text == 'Навсегда': + ban_days = None + else: + try: + ban_days = int(message.text) + if ban_days <= 0: + await message.answer("Срок блокировки должен быть положительным числом.") + return + except ValueError: + await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.") + return + + await state.update_data(ban_days=ban_days) + + # Показываем подтверждение + confirmation_text = format_ban_confirmation( + user_data['target_user_id'], + user_data['ban_reason'], + ban_days + ) + markup = create_keyboard_for_approve_ban() + await message.answer(confirmation_text, reply_markup=markup) + await state.set_state('BAN_CONFIRMATION') + + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_duration") + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("BAN_CONFIRMATION"), F.text == 'Подтвердить' ) -async def approve_ban(message: types.Message, state: FSMContext): - user_data = await state.get_data() - logger.info(f"Переход на финальный шаг бана пользователя. Словарь с данными для бана: {user_data})") - exists = BotDB.check_user_in_blacklist(user_data['user_id']) - if exists: - await message.reply(f"Пользователь уже был заблокирован ранее.") - logger.info(f"Пользователь: {user_data['user_id']} был заблокирован ранее)") - await state.set_state('ADMIN') - else: - BotDB.set_user_blacklist(user_data['user_id'], - user_data['user_name'], - user_data['message_for_user'], - user_data['date_to_unban']) - # Экранируем user_name для безопасного использования - safe_user_name = html.escape(str(user_data['user_name'])) if user_data.get('user_name') else "Неизвестный пользователь" - await message.reply(f"Пользователь {safe_user_name} успешно заблокирован.") - logger.info(f"Пользователь: {user_data['user_id']} успешно заблокирован)") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) +async def confirm_ban( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Подтверждение блокировки пользователя""" + try: + user_data = await state.get_data() + admin_service = AdminService(bot_db) + + + # Выполняем блокировку + admin_service.ban_user( + user_id=user_data['target_user_id'], + username=user_data['target_username'], + reason=user_data['ban_reason'], + ban_days=user_data['ban_days'] + ) + + safe_username = escape_html(user_data['target_username']) + await message.reply(f"Пользователь {safe_username} успешно заблокирован.") + await return_to_admin_menu(message, state) + + except UserAlreadyBannedError as e: + await message.reply(str(e)) + await return_to_admin_menu(message, state) + except Exception as e: + await handle_admin_error(message, e, state, "confirm_ban") + + +# ============================================================================ +# ХЕНДЛЕРЫ ОТМЕНЫ И НАВИГАЦИИ +# ============================================================================ + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"), + F.text == 'Отменить' +) +async def cancel_ban_process( + message: types.Message, + state: FSMContext + ): + """Отмена процесса блокировки""" + try: + current_state = await state.get_state() + logger.info(f"Отмена процедуры блокировки из состояния: {current_state}") + await return_to_admin_menu(message, state) + except Exception as e: + await handle_admin_error(message, e, state, "cancel_ban_process") + + +@admin_router.message(Command("test_metrics")) +async def test_metrics_handler( + message: types.Message, + bot_db: MagicData("bot_db") +): + """Тестовый хендлер для проверки метрик""" + from helper_bot.utils.metrics import metrics + + try: + # Принудительно записываем тестовые метрики + metrics.record_command("test_metrics", "admin_handler", "admin", "success") + metrics.record_message("text", "private", "admin_handler") + metrics.record_error("TestError", "admin_handler", "test_metrics_handler") + + # Проверяем активных пользователей + if hasattr(bot_db, 'connect') and hasattr(bot_db, 'cursor'): + active_users_query = """ + SELECT COUNT(DISTINCT user_id) as active_users + FROM our_users + WHERE date_changed > datetime('now', '-1 day') + """ + try: + bot_db.connect() + bot_db.cursor.execute(active_users_query) + result = bot_db.cursor.fetchone() + active_users = result[0] if result else 0 + finally: + bot_db.close() + else: + active_users = "N/A" + + await message.answer( + f"✅ Тестовые метрики записаны\n" + f"📊 Активных пользователей: {active_users}\n" + f"🔧 Проверьте Grafana дашборд" + ) + + except Exception as e: + await message.answer(f"❌ Ошибка тестирования метрик: {e}") diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py new file mode 100644 index 0000000..39c5572 --- /dev/null +++ b/helper_bot/handlers/admin/dependencies.py @@ -0,0 +1,64 @@ +from typing import Dict, Any +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from helper_bot.utils.base_dependency_factory import get_global_instance +from helper_bot.utils.helper_func import check_access +from logs.custom_logger import logger + + +class AdminAccessMiddleware(BaseMiddleware): + """Middleware для проверки административного доступа""" + + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + if hasattr(event, 'from_user'): + user_id = event.from_user.id + + # Получаем bot_db из data (внедренного DependenciesMiddleware) + bot_db = data.get('bot_db') + if not bot_db: + # Fallback: получаем напрямую если middleware не сработала + bdf = get_global_instance() + bot_db = bdf.get_db() + + if not check_access(user_id, bot_db): + if hasattr(event, 'answer'): + await event.answer('Доступ запрещен!') + return + + try: + # Вызываем хендлер с data + return await handler(event, data) + except TypeError as e: + if "missing 1 required positional argument: 'data'" in str(e): + logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'") + # Пытаемся вызвать хендлер без data (для совместимости с MagicData) + return await handler(event) + else: + logger.error(f"TypeError в AdminAccessMiddleware: {e}") + raise + except Exception as e: + logger.error(f"Неожиданная ошибка в AdminAccessMiddleware: {e}") + raise + + +# Dependency providers +def get_bot_db(): + """Провайдер для получения экземпляра БД""" + bdf = get_global_instance() + return bdf.get_db() + + +def get_settings(): + """Провайдер для получения настроек""" + bdf = get_global_instance() + return bdf.settings + + +# Type aliases for dependency injection +BotDB = Annotated[object, get_bot_db()] +Settings = Annotated[dict, get_settings()] diff --git a/helper_bot/handlers/admin/exceptions.py b/helper_bot/handlers/admin/exceptions.py new file mode 100644 index 0000000..8ad1fed --- /dev/null +++ b/helper_bot/handlers/admin/exceptions.py @@ -0,0 +1,23 @@ +class AdminError(Exception): + """Базовое исключение для административных операций""" + pass + + +class AdminAccessDeniedError(AdminError): + """Исключение при отказе в административном доступе""" + pass + + +class UserNotFoundError(AdminError): + """Исключение при отсутствии пользователя""" + pass + + +class InvalidInputError(AdminError): + """Исключение при некорректном вводе данных""" + pass + + +class UserAlreadyBannedError(AdminError): + """Исключение при попытке забанить уже заблокированного пользователя""" + pass diff --git a/helper_bot/handlers/admin/services.py b/helper_bot/handlers/admin/services.py new file mode 100644 index 0000000..126fd5c --- /dev/null +++ b/helper_bot/handlers/admin/services.py @@ -0,0 +1,146 @@ +from typing import List, Optional +from datetime import datetime + +from helper_bot.utils.helper_func import add_days_to_date, get_banned_users_buttons, get_banned_users_list +from helper_bot.handlers.admin.exceptions import UserAlreadyBannedError, InvalidInputError +from logs.custom_logger import logger + + +class User: + """Модель пользователя""" + def __init__(self, user_id: int, username: str, full_name: str): + self.user_id = user_id + self.username = username + self.full_name = full_name + + +class BannedUser: + """Модель заблокированного пользователя""" + def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]): + self.user_id = user_id + self.username = username + self.reason = reason + self.unban_date = unban_date + + +class AdminService: + """Сервис для административных операций""" + + def __init__(self, bot_db): + self.bot_db = bot_db + + def get_last_users(self) -> List[User]: + """Получить список последних пользователей""" + try: + users_data = self.bot_db.get_last_users_from_db() + return [ + User( + user_id=user[1], + username='Неизвестно', + full_name=user[0] + ) + for user in users_data + ] + except Exception as e: + logger.error(f"Ошибка при получении списка последних пользователей: {e}") + raise + + def get_banned_users(self) -> List[BannedUser]: + """Получить список заблокированных пользователей""" + try: + banned_users_data = self.bot_db.get_banned_users_from_db() + return [ + BannedUser( + user_id=user[1], # user_id + username=user[0], # user_name + reason=user[2], # message_for_user + unban_date=user[3] # date_to_unban + ) + for user in banned_users_data + ] + except Exception as e: + logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}") + raise + + def get_user_by_username(self, username: str) -> Optional[User]: + """Получить пользователя по username""" + try: + user_id = self.bot_db.get_user_id_by_username(username) + if not user_id: + return None + + full_name = self.bot_db.get_full_name_by_id(user_id) + return User( + user_id=user_id, + username=username, + full_name=full_name or 'Неизвестно' + ) + except Exception as e: + logger.error(f"Ошибка при поиске пользователя по username {username}: {e}") + raise + + def get_user_by_id(self, user_id: int) -> Optional[User]: + """Получить пользователя по ID""" + try: + user_info = self.bot_db.get_user_info_by_id(user_id) + if not user_info: + return None + + return User( + user_id=user_id, + username=user_info.get('username', 'Неизвестно'), + full_name=user_info.get('full_name', 'Неизвестно') + ) + except Exception as e: + logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}") + raise + + def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int]) -> None: + """Заблокировать пользователя""" + try: + # Проверяем, не заблокирован ли уже пользователь + if self.bot_db.check_user_in_blacklist(user_id): + raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован") + + # Рассчитываем дату разблокировки + date_to_unban = None + if ban_days is not None: + date_to_unban = add_days_to_date(ban_days) + + # Сохраняем в БД + self.bot_db.set_user_blacklist(user_id, username, reason, date_to_unban) + + logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней") + + except Exception as e: + logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}") + raise + + def unban_user(self, user_id: int) -> None: + """Разблокировать пользователя""" + try: + self.bot_db.delete_user_blacklist(user_id) + logger.info(f"Пользователь {user_id} разблокирован") + except Exception as e: + logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}") + raise + + def validate_user_input(self, input_text: str) -> int: + """Валидация введенного ID пользователя""" + try: + user_id = int(input_text.strip()) + if user_id <= 0: + raise InvalidInputError("ID пользователя должен быть положительным числом") + return user_id + except ValueError: + raise InvalidInputError("ID пользователя должен быть числом") + + def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]: + """Получить данные заблокированных пользователей для отображения""" + try: + message_text = get_banned_users_list(page, self.bot_db) + buttons_list = get_banned_users_buttons(self.bot_db) + return message_text, buttons_list + except Exception as e: + logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}") + raise diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py new file mode 100644 index 0000000..2e52a18 --- /dev/null +++ b/helper_bot/handlers/admin/utils.py @@ -0,0 +1,61 @@ +import html +from typing import Optional +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.keyboards.keyboards import get_reply_keyboard_admin +from helper_bot.handlers.admin.exceptions import AdminError +from logs.custom_logger import logger + + +def escape_html(text: str) -> str: + """Экранирование HTML для безопасного использования в сообщениях""" + return html.escape(str(text)) if text else "" + + +async def return_to_admin_menu(message: types.Message, state: FSMContext, + additional_message: Optional[str] = None) -> None: + """Универсальная функция для возврата в админ-меню""" + await state.set_data({}) + await state.set_state("ADMIN") + markup = get_reply_keyboard_admin() + + if additional_message: + await message.answer(additional_message) + + await message.answer('Вернулись в меню', reply_markup=markup) + + +async def handle_admin_error(message: types.Message, error: Exception, + state: FSMContext, error_context: str = "") -> None: + """Централизованная обработка ошибок административных операций""" + logger.error(f"Ошибка в {error_context}: {error}") + + if isinstance(error, AdminError): + await message.answer(f"Ошибка: {str(error)}") + else: + await message.answer("Произошла внутренняя ошибка. Попробуйте позже.") + + await return_to_admin_menu(message, state) + + +def format_user_info(user_id: int, username: str, full_name: str) -> str: + """Форматирование информации о пользователе для отображения""" + safe_username = escape_html(username) + safe_full_name = escape_html(full_name) + + return (f"Выбран пользователь:\n" + f"ID: {user_id}\n" + f"Username: {safe_username}\n" + f"Имя: {safe_full_name}") + + +def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str: + """Форматирование подтверждения бана""" + safe_reason = escape_html(reason) + ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней" + + return (f"Необходимо подтверждение:\n" + f"Пользователь: {user_id}\n" + f"Причина бана: {safe_reason}\n" + f"Срок бана: {ban_text}") diff --git a/helper_bot/handlers/callback/__init__.py b/helper_bot/handlers/callback/__init__.py index 9e5a0e2..6bb7d74 100644 --- a/helper_bot/handlers/callback/__init__.py +++ b/helper_bot/handlers/callback/__init__.py @@ -1 +1,24 @@ from .callback_handlers import callback_router +from .services import PostPublishService, BanService +from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError +from .constants import ( + CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK, + CALLBACK_RETURN, CALLBACK_PAGE +) + +__all__ = [ + 'callback_router', + 'PostPublishService', + 'BanService', + 'UserBlockedBotError', + 'PostNotFoundError', + 'UserNotFoundError', + 'PublishError', + 'BanError', + 'CALLBACK_PUBLISH', + 'CALLBACK_DECLINE', + 'CALLBACK_BAN', + 'CALLBACK_UNLOCK', + 'CALLBACK_RETURN', + 'CALLBACK_PAGE' +] diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index cbb5b90..e18f359 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -1,284 +1,187 @@ -import html -import traceback - -from aiogram import Router, F -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery - -from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \ - create_keyboard_for_ban_reason -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import send_text_message, send_photo_message, get_banned_users_list, \ - get_banned_users_buttons, delete_user_blacklist, send_media_group_to_channel, \ - send_video_message, send_video_note_message, send_audio_message, send_voice_message -from logs.custom_logger import logger - -callback_router = Router() - -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] - -BotDB = bdf.get_db() - - -@callback_router.callback_query( - F.data == "publish" -) -async def post_for_group(call: CallbackQuery, state: FSMContext): - logger.info( - f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') - text_post = html.escape(str(call.message.text)) - text_post_with_photo = html.escape(str(call.message.caption)) - if call.message.content_type == 'text' and call.message.text != "^": - try: - # Пересылаем сообщение в канал - await send_text_message(MAIN_PUBLIC, call.message, text_post) - - # Получаем из базы автора - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - # Очищаем предложку и удаляем оттуда пост - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Текст сообщения опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - - # Отвечаем пользователю - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации текста в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'photo': - try: - await send_photo_message(MAIN_PUBLIC, call.message, call.message.photo[-1].file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - # Удаляем пост из предложки - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с фото опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации фотографии в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'video': - try: - await send_video_message(MAIN_PUBLIC, call.message, call.message.video.file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с видео опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации видео в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'video_note': - try: - await send_video_note_message(MAIN_PUBLIC, call.message, call.message.video_note.file_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с кружком опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации кружка в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'audio': - try: - await send_audio_message(MAIN_PUBLIC, call.message, call.message.audio.file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с аудио опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации аудио в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'voice': - try: - await send_voice_message(MAIN_PUBLIC, call.message, call.message.voice.file_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с войсом опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации войса в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.text == "^": - # Получаем контент медиагруппы и текст для публикации - post_content = BotDB.get_post_content_from_telegram_by_last_id(call.message.message_id) - pre_text = BotDB.get_post_text_from_telegram_by_last_id(call.message.message_id) - post_text = html.escape(str(pre_text)) - - # Готовим список для удаления - post_ids = BotDB.get_post_ids_from_telegram_by_last_id(call.message.message_id) - message_ids = [row[0] for row in post_ids] - message_ids.append(call.message.message_id) - - # Выкладываем пост в канал - await send_media_group_to_channel(bot=call.bot, chat_id=MAIN_PUBLIC, post_content=post_content, - post_text=post_text) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_helper_message_id(call.message.message_id) - - # TODO: Удалить фотки с локалки после выкладки? - await call.bot.delete_messages(chat_id=GROUP_FOR_POST, message_ids=message_ids) - await call.answer(text='Выложено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - - -@callback_router.callback_query( - F.data == "decline" -) -async def decline_post_for_group(call: CallbackQuery, state: FSMContext): - logger.info( - f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') - try: - if call.message.content_type == 'text' and call.message.text != "^" or call.message.content_type == 'photo' \ - or call.message.content_type == 'audio' or call.message.content_type == 'voice' \ - or call.message.content_type == 'video' or call.message.content_type == 'video_note': - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - logger.info( - f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') - await call.answer(text='Отклонено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был отклонен😔') - if call.message.text == '^': - post_ids = BotDB.get_post_ids_from_telegram_by_last_id(call.message.message_id) - message_ids = [row[0] for row in post_ids] - message_ids.append(call.message.message_id) - - await call.bot.delete_messages(chat_id=GROUP_FOR_POST, message_ids=message_ids) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_helper_message_id(call.message.message_id) - - await call.answer(text='Удалено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был отклонен😔') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(IMPORTANT_LOGS, - f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при удалении сообщения в группе {GROUP_FOR_POST}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - - -@callback_router.callback_query( - F.data.contains('ban') -) -async def process_ban_user(call: CallbackQuery, state: FSMContext): - user_id = call.data[4:] - logger.info( - f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}") - user_name = BotDB.get_username(user_id=user_id) - if user_name: - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(call.message.from_user.full_name)) - await call.message.answer( - text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - else: - markup = get_reply_keyboard_admin() - await call.message.answer(text='Пользователь с таким ID не найден в базе', markup=markup) - await state.set_state('ADMIN') - - -@callback_router.callback_query( - F.data.contains('unlock') -) -async def process_unlock_user(call: CallbackQuery): - user_id = call.data[7:] - user_name = BotDB.get_username(user_id=user_id) - delete_user_blacklist(user_id, BotDB) - logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") - username = BotDB.get_username(user_id) - await call.answer(f'Пользователь разблокирован {username}', show_alert=True) - - -@callback_router.callback_query( - F.data == 'return' -) -async def return_to_main_menu(call: CallbackQuery): - await call.message.delete() - logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") - markup = get_reply_keyboard_admin() - await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", - reply_markup=markup) - - -@callback_router.callback_query( - F.data.contains('page') -) -async def change_page(call: CallbackQuery): - page_number = int(call.data[5:]) - logger.info(f"Переход на страницу {page_number}") - if call.message.text == 'Список пользователей которые последними обращались к боту': - list_users = BotDB.get_last_users_from_db() - # TODO: Здесь где-то надо добавить обработку ошибки IndexError: list index out of range - keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, - 'ban') - - await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, - reply_markup=keyboard) - else: - # Готовим сообщения - message_user = get_banned_users_list(int(page_number) * 7 - 7, BotDB) - await call.bot.edit_message_text(chat_id=call.message.chat.id, message_id=call.message.message_id, - text=message_user) - - # Готовим клавиатуру - buttons = get_banned_users_buttons(BotDB) - keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock') - await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, - reply_markup=keyboard) +import html +import traceback + +from aiogram import Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery +from aiogram import F +from aiogram.filters import MagicData + +from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \ + create_keyboard_for_ban_reason +from helper_bot.utils.helper_func import get_banned_users_list, get_banned_users_buttons +from helper_bot.utils.base_dependency_factory import get_global_instance +from .dependency_factory import get_post_publish_service, get_ban_service +from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError +from .constants import ( + CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK, + CALLBACK_RETURN, CALLBACK_PAGE, MESSAGE_PUBLISHED, MESSAGE_DECLINED, + MESSAGE_USER_BANNED, MESSAGE_USER_UNLOCKED, MESSAGE_ERROR, + ERROR_BOT_BLOCKED +) +from logs.custom_logger import logger + +callback_router = Router() + + +@callback_router.callback_query(F.data == CALLBACK_PUBLISH) +async def post_for_group( + call: CallbackQuery, + settings: MagicData("settings") + ): + publish_service = get_post_publish_service() + # TODO: переделать на MagicData + logger.info( + f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') + + try: + await publish_service.publish_post(call) + await call.answer(text=MESSAGE_PUBLISHED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (PostNotFoundError, PublishError) as e: + logger.error(f'Ошибка при публикации поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + important_logs = settings['Telegram']['important_logs'] + await call.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + + +@callback_router.callback_query(F.data == CALLBACK_DECLINE) +async def decline_post_for_group( + call: CallbackQuery, + settings: MagicData("settings") + ): + publish_service = get_post_publish_service() + # TODO: переделать на MagicData + logger.info( + f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') + try: + await publish_service.decline_post(call) + await call.answer(text=MESSAGE_DECLINED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (PostNotFoundError, PublishError) as e: + logger.error(f'Ошибка при отклонении поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + important_logs = settings['Telegram']['important_logs'] + await call.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + + +@callback_router.callback_query(F.data == CALLBACK_BAN) +async def ban_user_from_post(call: CallbackQuery): + ban_service = get_ban_service() + # TODO: переделать на MagicData + try: + await ban_service.ban_user_from_post(call) + await call.answer(text=MESSAGE_USER_BANNED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (UserNotFoundError, BanError) as e: + logger.error(f'Ошибка при блокировке пользователя: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + + +@callback_router.callback_query(F.data.contains(CALLBACK_BAN)) +async def process_ban_user(call: CallbackQuery, state: FSMContext): + ban_service = get_ban_service() + # TODO: переделать на MagicData + user_id = call.data[4:] + logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}") + + try: + user_name = await ban_service.ban_user(user_id, "") + await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, date_to_unban=None) + markup = create_keyboard_for_ban_reason() + + user_name_escaped = html.escape(str(user_name)) + full_name_escaped = html.escape(str(call.message.from_user.full_name)) + await call.message.answer( + text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", + reply_markup=markup + ) + await state.set_state('BAN_2') + except UserNotFoundError: + markup = get_reply_keyboard_admin() + await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup) + await state.set_state('ADMIN') + + +@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK)) +async def process_unlock_user(call: CallbackQuery): + ban_service = get_ban_service() + # TODO: переделать на MagicData + user_id = call.data[7:] + + try: + username = await ban_service.unlock_user(user_id) + await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True) + except UserNotFoundError: + await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3) + except Exception as e: + logger.error(f'Ошибка при разблокировке пользователя: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + + +@callback_router.callback_query(F.data == CALLBACK_RETURN) +async def return_to_main_menu(call: CallbackQuery): + await call.message.delete() + logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") + markup = get_reply_keyboard_admin() + await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) + + +@callback_router.callback_query(F.data.contains(CALLBACK_PAGE)) +async def change_page( + call: CallbackQuery, + bot_db: MagicData("bot_db") + ): + page_number = int(call.data[5:]) + logger.info(f"Переход на страницу {page_number}") + + if call.message.text == 'Список пользователей которые последними обращались к боту': + list_users = bot_db.get_last_users_from_db() + keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, 'ban') + await call.bot.edit_message_reply_markup( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + reply_markup=keyboard + ) + else: + message_user = get_banned_users_list(int(page_number) * 7 - 7, bot_db) + await call.bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + text=message_user + ) + + buttons = get_banned_users_buttons(bot_db) + keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock') + await call.bot.edit_message_reply_markup( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + reply_markup=keyboard + ) diff --git a/helper_bot/handlers/callback/constants.py b/helper_bot/handlers/callback/constants.py new file mode 100644 index 0000000..a0524fa --- /dev/null +++ b/helper_bot/handlers/callback/constants.py @@ -0,0 +1,29 @@ +# Callback data constants +CALLBACK_PUBLISH = "publish" +CALLBACK_DECLINE = "decline" +CALLBACK_BAN = "ban" +CALLBACK_UNLOCK = "unlock" +CALLBACK_RETURN = "return" +CALLBACK_PAGE = "page" + +# Content types +CONTENT_TYPE_TEXT = "text" +CONTENT_TYPE_PHOTO = "photo" +CONTENT_TYPE_VIDEO = "video" +CONTENT_TYPE_VIDEO_NOTE = "video_note" +CONTENT_TYPE_AUDIO = "audio" +CONTENT_TYPE_VOICE = "voice" +CONTENT_TYPE_MEDIA_GROUP = "^" + +# Messages +MESSAGE_PUBLISHED = "Выложено!" +MESSAGE_DECLINED = "Отклонено!" +MESSAGE_USER_BANNED = "Пользователь заблокирован!" +MESSAGE_USER_UNLOCKED = "Пользователь разблокирован" +MESSAGE_ERROR = "Что-то пошло не так!" +MESSAGE_POST_PUBLISHED = "Твой пост был выложен🥰" +MESSAGE_POST_DECLINED = "Твой пост был отклонен😔" +MESSAGE_USER_BANNED_SPAM = "Ты заблокирован за спам. Дата разблокировки: {date}" + +# Error messages +ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user" diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py new file mode 100644 index 0000000..749b36f --- /dev/null +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -0,0 +1,33 @@ +from typing import Callable +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.fsm.context import FSMContext + +from helper_bot.utils.base_dependency_factory import get_global_instance +from .services import PostPublishService, BanService + + +def get_post_publish_service() -> PostPublishService: + """Фабрика для PostPublishService""" + bdf = get_global_instance() + bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + db = bdf.get_db() + settings = bdf.settings + return PostPublishService(bot, db, settings) + + +def get_ban_service() -> BanService: + """Фабрика для BanService""" + bdf = get_global_instance() + bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + db = bdf.get_db() + settings = bdf.settings + return BanService(bot, db, settings) diff --git a/helper_bot/handlers/callback/exceptions.py b/helper_bot/handlers/callback/exceptions.py new file mode 100644 index 0000000..5b1dc73 --- /dev/null +++ b/helper_bot/handlers/callback/exceptions.py @@ -0,0 +1,23 @@ +class UserBlockedBotError(Exception): + """Исключение, возникающее когда пользователь заблокировал бота""" + pass + + +class PostNotFoundError(Exception): + """Исключение, возникающее когда пост не найден в базе данных""" + pass + + +class UserNotFoundError(Exception): + """Исключение, возникающее когда пользователь не найден в базе данных""" + pass + + +class PublishError(Exception): + """Общее исключение для ошибок публикации""" + pass + + +class BanError(Exception): + """Исключение для ошибок бана/разбана пользователей""" + pass diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py new file mode 100644 index 0000000..663a7e1 --- /dev/null +++ b/helper_bot/handlers/callback/services.py @@ -0,0 +1,249 @@ +import html +from datetime import datetime, timedelta +from typing import Dict, Any + +from aiogram import Bot +from aiogram.types import CallbackQuery + +from helper_bot.utils.helper_func import ( + send_text_message, send_photo_message, send_video_message, + send_video_note_message, send_audio_message, send_voice_message, + send_media_group_to_channel, delete_user_blacklist +) +from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason +from .exceptions import ( + UserBlockedBotError, PostNotFoundError, UserNotFoundError, + PublishError, BanError +) +from .constants import ( + CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_VIDEO, + CONTENT_TYPE_VIDEO_NOTE, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, + CONTENT_TYPE_MEDIA_GROUP, MESSAGE_POST_PUBLISHED, MESSAGE_POST_DECLINED, + MESSAGE_USER_BANNED_SPAM, ERROR_BOT_BLOCKED +) +from logs.custom_logger import logger + + +class PostPublishService: + def __init__(self, bot: Bot, db, settings: Dict[str, Any]): + self.bot = bot + self.db = db + self.settings = settings + self.group_for_posts = settings['Telegram']['group_for_posts'] + self.main_public = settings['Telegram']['main_public'] + self.important_logs = settings['Telegram']['important_logs'] + + async def publish_post(self, call: CallbackQuery) -> None: + """Основной метод публикации поста""" + content_type = call.message.content_type + + if content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP: + await self._publish_text_post(call) + elif content_type == CONTENT_TYPE_PHOTO: + await self._publish_photo_post(call) + elif content_type == CONTENT_TYPE_VIDEO: + await self._publish_video_post(call) + elif content_type == CONTENT_TYPE_VIDEO_NOTE: + await self._publish_video_note_post(call) + elif content_type == CONTENT_TYPE_AUDIO: + await self._publish_audio_post(call) + elif content_type == CONTENT_TYPE_VOICE: + await self._publish_voice_post(call) + elif call.message.text == CONTENT_TYPE_MEDIA_GROUP: + await self._publish_media_group(call) + else: + raise PublishError(f"Неподдерживаемый тип контента: {content_type}") + + async def _publish_text_post(self, call: CallbackQuery) -> None: + """Публикация текстового поста""" + text_post = html.escape(str(call.message.text)) + author_id = self._get_author_id(call.message.message_id) + + await send_text_message(self.main_public, call.message, text_post) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Текст сообщения опубликован в канале {self.main_public}.') + + async def _publish_photo_post(self, call: CallbackQuery) -> None: + """Публикация поста с фото""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с фото опубликован в канале {self.main_public}.') + + async def _publish_video_post(self, call: CallbackQuery) -> None: + """Публикация поста с видео""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_video_message(self.main_public, call.message, call.message.video.file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с видео опубликован в канале {self.main_public}.') + + async def _publish_video_note_post(self, call: CallbackQuery) -> None: + """Публикация поста с кружком""" + author_id = self._get_author_id(call.message.message_id) + + await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с кружком опубликован в канале {self.main_public}.') + + async def _publish_audio_post(self, call: CallbackQuery) -> None: + """Публикация поста с аудио""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_audio_message(self.main_public, call.message, call.message.audio.file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с аудио опубликован в канале {self.main_public}.') + + async def _publish_voice_post(self, call: CallbackQuery) -> None: + """Публикация поста с войсом""" + author_id = self._get_author_id(call.message.message_id) + + await send_voice_message(self.main_public, call.message, call.message.voice.file_id) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с войсом опубликован в канале {self.main_public}.') + + async def _publish_media_group(self, call: CallbackQuery) -> None: + """Публикация медиагруппы""" + post_content = self.db.get_post_content_from_telegram_by_last_id(call.message.message_id) + pre_text = self.db.get_post_text_from_telegram_by_last_id(call.message.message_id) + post_text = html.escape(str(pre_text)) + author_id = self._get_author_id_for_media_group(call.message.message_id) + + await send_media_group_to_channel(bot=self.bot, chat_id=self.main_public, post_content=post_content, post_text=post_text) + await self._delete_media_group_and_notify_author(call, author_id) + + async def decline_post(self, call: CallbackQuery) -> None: + """Отклонение поста""" + content_type = call.message.content_type + + if (content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP) or \ + content_type in [CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]: + await self._decline_single_post(call) + elif call.message.text == CONTENT_TYPE_MEDIA_GROUP: + await self._decline_media_group(call) + else: + raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}") + + async def _decline_single_post(self, call: CallbackQuery) -> None: + """Отклонение одиночного поста""" + author_id = self._get_author_id(call.message.message_id) + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') + + async def _decline_media_group(self, call: CallbackQuery) -> None: + """Отклонение медиагруппы""" + post_ids = self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) + message_ids = [row[0] for row in post_ids] + message_ids.append(call.message.message_id) + + author_id = self._get_author_id_for_media_group(call.message.message_id) + await self.bot.delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + def _get_author_id(self, message_id: int) -> int: + """Получение ID автора по ID сообщения""" + author_id = self.db.get_author_id_by_message_id(message_id) + if not author_id: + raise PostNotFoundError(f"Автор не найден для сообщения {message_id}") + return author_id + + def _get_author_id_for_media_group(self, message_id: int) -> int: + """Получение ID автора для медиагруппы""" + author_id = self.db.get_author_id_by_helper_message_id(message_id) + if not author_id: + raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}") + return author_id + + async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: + """Удаление поста и уведомление автора""" + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: + """Удаление медиагруппы и уведомление автора""" + post_ids = self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) + message_ids = [row[0] for row in post_ids] + message_ids.append(call.message.message_id) + await self.bot.delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + +class BanService: + def __init__(self, bot: Bot, db, settings: Dict[str, Any]): + self.bot = bot + self.db = db + self.settings = settings + self.group_for_posts = settings['Telegram']['group_for_posts'] + self.important_logs = settings['Telegram']['important_logs'] + + async def ban_user_from_post(self, call: CallbackQuery) -> None: + """Бан пользователя за спам""" + author_id = self.db.get_author_id_by_message_id(call.message.message_id) + if not author_id: + raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}") + + user_name = self.db.get_username(user_id=author_id) + current_date = datetime.now() + date_to_unban = current_date + timedelta(days=7) + + self.db.set_user_blacklist( + user_id=author_id, + user_name=user_name, + message_for_user="Спам", + date_to_unban=date_to_unban + ) + + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + + date_str = date_to_unban.strftime("%d.%m.%Y %H:%M") + try: + await send_text_message(author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str)) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}") + + async def ban_user(self, user_id: str, user_name: str) -> str: + """Бан пользователя по ID""" + user_name = self.db.get_username(user_id=user_id) + if not user_name: + raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе") + + return user_name + + async def unlock_user(self, user_id: str) -> str: + """Разблокировка пользователя""" + user_name = self.db.get_username(user_id=user_id) + if not user_name: + raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе") + + delete_user_blacklist(user_id, self.db) + logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") + return user_name diff --git a/helper_bot/handlers/group/__init__.py b/helper_bot/handlers/group/__init__.py index 1958741..7c42b67 100644 --- a/helper_bot/handlers/group/__init__.py +++ b/helper_bot/handlers/group/__init__.py @@ -1 +1,47 @@ -from .group_handlers import group_router +"""Group handlers package for Telegram bot""" + +# Local imports - main components +from .group_handlers import ( + group_router, + create_group_handlers, + GroupHandlers +) + +# Local imports - services +from .services import ( + AdminReplyService, + DatabaseProtocol +) + +# Local imports - constants and utilities +from .constants import ( + FSM_STATES, + ERROR_MESSAGES +) +from .exceptions import ( + NoReplyToMessageError, + UserNotFoundError +) +from .decorators import error_handler + +__all__ = [ + # Main components + 'group_router', + 'create_group_handlers', + 'GroupHandlers', + + # Services + 'AdminReplyService', + 'DatabaseProtocol', + + # Constants + 'FSM_STATES', + 'ERROR_MESSAGES', + + # Exceptions + 'NoReplyToMessageError', + 'UserNotFoundError', + + # Utilities + 'error_handler' +] diff --git a/helper_bot/handlers/group/constants.py b/helper_bot/handlers/group/constants.py new file mode 100644 index 0000000..8f16169 --- /dev/null +++ b/helper_bot/handlers/group/constants.py @@ -0,0 +1,14 @@ +"""Constants for group handlers""" + +from typing import Final, Dict + +# FSM States +FSM_STATES: Final[Dict[str, str]] = { + "CHAT": "CHAT" +} + +# Error messages +ERROR_MESSAGES: Final[Dict[str, str]] = { + "NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!", + "USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение." +} diff --git a/helper_bot/handlers/group/decorators.py b/helper_bot/handlers/group/decorators.py new file mode 100644 index 0000000..e408969 --- /dev/null +++ b/helper_bot/handlers/group/decorators.py @@ -0,0 +1,36 @@ +"""Decorators and utility functions for group handlers""" + +# Standard library imports +import traceback +from typing import Any, Callable + +# Third-party imports +from aiogram import types + +# Local imports +from logs.custom_logger import logger + + +def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for centralized error handling""" + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"Error in {func.__name__}: {str(e)}") + # Try to send error to logs if possible + try: + message = next((arg for arg in args if isinstance(arg, types.Message)), None) + if message and hasattr(message, 'bot'): + from helper_bot.utils.base_dependency_factory import get_global_instance + bdf = get_global_instance() + important_logs = bdf.settings['Telegram']['important_logs'] + await message.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + except Exception: + # If we can't log the error, at least it was logged to logger + pass + raise + return wrapper diff --git a/helper_bot/handlers/group/exceptions.py b/helper_bot/handlers/group/exceptions.py new file mode 100644 index 0000000..e10a41c --- /dev/null +++ b/helper_bot/handlers/group/exceptions.py @@ -0,0 +1,11 @@ +"""Custom exceptions for group handlers""" + + +class NoReplyToMessageError(Exception): + """Raised when admin tries to reply without selecting a message""" + pass + + +class UserNotFoundError(Exception): + """Raised when user is not found in database for the given message_id""" + pass diff --git a/helper_bot/handlers/group/group_handlers.py b/helper_bot/handlers/group/group_handlers.py index 8f5528e..af1bc08 100644 --- a/helper_bot/handlers/group/group_handlers.py +++ b/helper_bot/handlers/group/group_handlers.py @@ -1,49 +1,113 @@ +"""Main group handlers module for Telegram bot""" + +# Third-party imports from aiogram import Router, types from aiogram.fsm.context import FSMContext +# Local imports - filters from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import send_text_message + +# Local imports - modular components +from .constants import FSM_STATES, ERROR_MESSAGES +from .services import AdminReplyService +from .decorators import error_handler +from .exceptions import UserNotFoundError + +# Local imports - utilities from logs.custom_logger import logger +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors +) + +class GroupHandlers: + """Main handler class for group messages""" + + def __init__(self, db, keyboard_markup: types.ReplyKeyboardMarkup): + self.db = db + self.keyboard_markup = keyboard_markup + self.admin_reply_service = AdminReplyService(db) + + # Create router + self.router = Router() + + # Register handlers + self._register_handlers() + + def _register_handlers(self): + """Register all message handlers""" + self.router.message.register( + self.handle_message, + ChatTypeFilter(chat_type=["group", "supergroup"]) + ) + + @error_handler + async def handle_message(self, message: types.Message, state: FSMContext): + """Handle admin reply to user through group chat""" + + logger.info( + f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) ' + f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"' + ) + + # Check if message is a reply + if not message.reply_to_message: + await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"]) + logger.warning( + f'В группе {message.chat.title} (ID: {message.chat.id}) ' + f'админ не выделил сообщение для ответа.' + ) + return + + message_id = message.reply_to_message.message_id + reply_text = message.text + + try: + # Get user ID for reply + chat_id = self.admin_reply_service.get_user_id_for_reply(message_id) + + # Send reply to user + await self.admin_reply_service.send_reply_to_user( + chat_id, message, reply_text, self.keyboard_markup + ) + + # Set state + await state.set_state(FSM_STATES["CHAT"]) + + except UserNotFoundError: + await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"]) + logger.error( + f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} ' + f'в группе {message.chat.title} (ID сообщения: {message.message_id})' + ) + + +# Factory function to create handlers with dependencies +def create_group_handlers(db, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers: + """Create group handlers instance with dependencies""" + return GroupHandlers(db, keyboard_markup) + + +# Legacy router for backward compatibility group_router = Router() -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] +# Initialize with global dependencies (for backward compatibility) +def init_legacy_router(): + """Initialize legacy router with global dependencies""" + global group_router + + from helper_bot.utils.base_dependency_factory import get_global_instance + from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat + + bdf = get_global_instance() + db = bdf.get_db() + keyboard_markup = get_reply_keyboard_leave_chat() + + handlers = create_group_handlers(db, keyboard_markup) + group_router = handlers.router -BotDB = bdf.get_db() - - -@group_router.message( - ChatTypeFilter(chat_type=["group", "supergroup"]), -) -async def handle_message(message: types.Message, state: FSMContext): - """Функция ответа админа пользователю через закрытый чат""" - logger.info( - f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"') - markup = get_reply_keyboard_leave_chat() - message_id = 0 - try: - message_id = message.reply_to_message.message_id - except AttributeError as e: - await message.answer('Блять, выдели сообщение!') - logger.warning( - f'В группе {message.chat.title} (ID: {message.chat.id}) админ не выделил сообщение для ответа. Ошибка {str(e)}') - message_from_admin = message.text - try: - chat_id = BotDB.get_user_by_message_id(message_id) - await send_text_message(chat_id, message, message_from_admin, markup) - await state.set_state("CHAT") - logger.info(f'Ответ админа "{message.text}" отправлен пользователю с ID: {chat_id} на сообщение {message_id}') - except TypeError as e: - await message.answer('Не могу найти кому ответить в базе, проебали сообщение.') - logger.error( - f'Ошибка при поиске пользователя в базе для ответа на сообщение: {message.text} в группе {message.chat.title} (ID сообщения: {message.message_id}) Ошибка: {str(e)}') +# Initialize legacy router +init_legacy_router() diff --git a/helper_bot/handlers/group/services.py b/helper_bot/handlers/group/services.py new file mode 100644 index 0000000..2c546b9 --- /dev/null +++ b/helper_bot/handlers/group/services.py @@ -0,0 +1,72 @@ +"""Service classes for group handlers""" + +# Standard library imports +from typing import Protocol, Optional + +# Third-party imports +from aiogram import types + +# Local imports +from helper_bot.utils.helper_func import send_text_message +from .exceptions import NoReplyToMessageError, UserNotFoundError +from logs.custom_logger import logger + +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors, + db_query_time +) + + +class DatabaseProtocol(Protocol): + """Protocol for database operations""" + def get_user_by_message_id(self, message_id: int) -> Optional[int]: ... + + +class AdminReplyService: + """Service for admin reply operations""" + + def __init__(self, db: DatabaseProtocol) -> None: + self.db = db + + def get_user_id_for_reply(self, message_id: int) -> int: + """ + Get user ID for reply by message ID. + + Args: + message_id: ID of the message to reply to + + Returns: + User ID for the reply + + Raises: + UserNotFoundError: If user is not found in database + """ + user_id = self.db.get_user_by_message_id(message_id) + if user_id is None: + raise UserNotFoundError(f"User not found for message_id: {message_id}") + return user_id + + async def send_reply_to_user( + self, + chat_id: int, + message: types.Message, + reply_text: str, + markup: types.ReplyKeyboardMarkup + ) -> None: + """ + Send reply to user. + + Args: + chat_id: User's chat ID + message: Original message from admin + reply_text: Text to send to user + markup: Reply keyboard markup + """ + await send_text_message(chat_id, message, reply_text, markup) + logger.info( + f'Ответ админа "{reply_text}" отправлен пользователю с ID: {chat_id} ' + f'на сообщение {message.reply_to_message.message_id if message.reply_to_message else "N/A"}' + ) diff --git a/helper_bot/handlers/private/__init__.py b/helper_bot/handlers/private/__init__.py index ba9e61a..b1bea9f 100644 --- a/helper_bot/handlers/private/__init__.py +++ b/helper_bot/handlers/private/__init__.py @@ -1 +1,45 @@ -from .private_handlers import private_router +"""Private handlers package for Telegram bot""" + +# Local imports - main components +from .private_handlers import ( + private_router, + create_private_handlers, + PrivateHandlers +) + +# Local imports - services +from .services import ( + BotSettings, + UserService, + PostService, + StickerService +) + +# Local imports - constants and utilities +from .constants import ( + FSM_STATES, + BUTTON_TEXTS, + ERROR_MESSAGES +) +from .decorators import error_handler + +__all__ = [ + # Main components + 'private_router', + 'create_private_handlers', + 'PrivateHandlers', + + # Services + 'BotSettings', + 'UserService', + 'PostService', + 'StickerService', + + # Constants + 'FSM_STATES', + 'BUTTON_TEXTS', + 'ERROR_MESSAGES', + + # Utilities + 'error_handler' +] diff --git a/helper_bot/handlers/private/constants.py b/helper_bot/handlers/private/constants.py new file mode 100644 index 0000000..5b87a68 --- /dev/null +++ b/helper_bot/handlers/private/constants.py @@ -0,0 +1,31 @@ +"""Constants for private handlers""" + +from typing import Final, Dict + +# FSM States +FSM_STATES: Final[Dict[str, str]] = { + "START": "START", + "SUGGEST": "SUGGEST", + "PRE_CHAT": "PRE_CHAT", + "CHAT": "CHAT" +} + +# Button texts +BUTTON_TEXTS: Final[Dict[str, str]] = { + "SUGGEST_POST": "📢Предложить свой пост", + "SAY_GOODBYE": "👋🏼Сказать пока!", + "LEAVE_CHAT": "Выйти из чата", + "RETURN_TO_BOT": "Вернуться в бота", + "WANT_STICKERS": "🤪Хочу стикеры", + "CONNECT_ADMIN": "📩Связаться с админами" +} + +# Error messages +ERROR_MESSAGES: Final[Dict[str, str]] = { + "UNSUPPORTED_CONTENT": ( + 'Я пока не умею работать с таким сообщением. ' + 'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n' + 'Мы добавим его к обработке если необходимо' + ), + "STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk" +} diff --git a/helper_bot/handlers/private/decorators.py b/helper_bot/handlers/private/decorators.py new file mode 100644 index 0000000..3b7e4b2 --- /dev/null +++ b/helper_bot/handlers/private/decorators.py @@ -0,0 +1,36 @@ +"""Decorators and utility functions for private handlers""" + +# Standard library imports +import traceback +from typing import Any, Callable + +# Third-party imports +from aiogram import types + +# Local imports +from logs.custom_logger import logger + + +def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for centralized error handling""" + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"Error in {func.__name__}: {str(e)}") + # Try to send error to logs if possible + try: + message = next((arg for arg in args if isinstance(arg, types.Message)), None) + if message and hasattr(message, 'bot'): + from helper_bot.utils.base_dependency_factory import get_global_instance + bdf = get_global_instance() + important_logs = bdf.settings['Telegram']['important_logs'] + await message.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + except Exception: + # If we can't log the error, at least it was logged to logger + pass + raise + return wrapper diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index 7bb9e75..506107d 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -1,503 +1,238 @@ -import random -import traceback -import asyncio -import html -from datetime import datetime -from pathlib import Path +"""Main private handlers module for Telegram bot""" +# Standard library imports +import asyncio +from datetime import datetime + +# Third-party imports from aiogram import types, Router, F from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext -from aiogram.types import FSInputFile +# Local imports - filters and middlewares from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post -from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat from helper_bot.middlewares.album_middleware import AlbumMiddleware from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware + +# Local imports - utilities +from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post +from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat from helper_bot.utils import messages -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import get_first_name, get_text_message, send_text_message, send_photo_message, \ - send_media_group_message_to_private_chat, prepare_media_group_from_middlewares, send_video_message, \ - send_video_note_message, send_audio_message, send_voice_message, add_in_db_media, \ - check_user_emoji, check_username_and_full_name, update_user_info -from logs.custom_logger import logger +from helper_bot.utils.helper_func import ( + get_first_name, + update_user_info, + check_user_emoji +) -private_router = Router() +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors +) -private_router.message.middleware(AlbumMiddleware()) -private_router.message.middleware(BlacklistMiddleware()) - -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] - -BotDB = bdf.get_db() +# Local imports - modular components +from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES +from .services import BotSettings, UserService, PostService, StickerService +from .decorators import error_handler # Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep) sleep = asyncio.sleep -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - Command("emoji") -) -async def handle_emoji_message(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) - user_emoji = check_user_emoji(message) - await state.set_state("START") - if user_emoji is not None: - await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') - -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - Command("restart") -) -async def handle_restart_message(message: types.Message, state: FSMContext): - try: - markup = get_reply_keyboard(BotDB, message.from_user.id) - await message.forward(chat_id=GROUP_FOR_LOGS) - await state.set_state("START") +class PrivateHandlers: + """Main handler class for private messages""" + + def __init__(self, db, settings: BotSettings): + self.db = db + self.settings = settings + self.user_service = UserService(db, settings) + self.post_service = PostService(db, settings) + self.sticker_service = StickerService(settings) + + # Create router + self.router = Router() + self.router.message.middleware(AlbumMiddleware()) + self.router.message.middleware(BlacklistMiddleware()) + + # Register handlers + self._register_handlers() + + def _register_handlers(self): + """Register all message handlers""" + # Command handlers + self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji")) + self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart")) + self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), Command("start")) + self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["RETURN_TO_BOT"]) + + # Button handlers + self.router.message.register(self.suggest_post, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SUGGEST_POST"]) + self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SAY_GOODBYE"]) + self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["LEAVE_CHAT"]) + self.router.message.register(self.stickers, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["WANT_STICKERS"]) + self.router.message.register(self.connect_with_admin, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["CONNECT_ADMIN"]) + + # State handlers + self.router.message.register(self.suggest_router, StateFilter(FSM_STATES["SUGGEST"]), ChatTypeFilter(chat_type=["private"])) + self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["PRE_CHAT"]), ChatTypeFilter(chat_type=["private"])) + self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["CHAT"]), ChatTypeFilter(chat_type=["private"])) + + @error_handler + async def handle_emoji_message(self, message: types.Message, state: FSMContext, **kwargs): + """Handle emoji command""" + await self.user_service.log_user_message(message) + user_emoji = check_user_emoji(message) + await state.set_state(FSM_STATES["START"]) + if user_emoji is not None: + await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') + + @error_handler + async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs): + """Handle restart command""" + markup = get_reply_keyboard(self.db, message.from_user.id) + await self.user_service.log_user_message(message) + await state.set_state(FSM_STATES["START"]) await update_user_info('love', message) check_user_emoji(message) await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML') - except Exception as e: - logger.error(f"Произошла ошибка handle_restart_message. Ошибка:{str(e)}") - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка handle_restart_message: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - - -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - Command("start") -) -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - F.text == 'Вернуться в бота' -) -async def handle_start_message(message: types.Message, state: FSMContext): - try: - await message.forward(chat_id=GROUP_FOR_LOGS) - full_name = message.from_user.full_name - username = message.from_user.username - first_name = get_first_name(message) - is_bot = message.from_user.is_bot - language_code = message.from_user.language_code - user_id = message.from_user.id + + @error_handler + async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs): + """Handle start command and return to bot button with metrics tracking""" + # User service operations with metrics + await self.user_service.log_user_message(message) + await self.user_service.ensure_user_exists(message) + await state.set_state(FSM_STATES["START"]) - # Проверяем наличие username для логирования - if not username: - # Экранируем full_name для безопасного использования - safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" - await message.bot.send_message(chat_id=GROUP_FOR_LOGS, - text=f'Пользователь {user_id} ({safe_full_name}) обратился к боту без username') - logger.warning(f"Пользователь {user_id} ({safe_full_name}) обратился к боту без username") - # Устанавливаем значение по умолчанию для username - username = "private_username" + # Send sticker with metrics + await self.sticker_service.send_random_hello_sticker(message) - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - if not BotDB.user_exists(user_id): - # Для первоначального добавления эмодзи пока не назначаем (совместимость) - BotDB.add_new_user_in_db(user_id, first_name, full_name, username, is_bot, language_code, "", date, - date) - else: - is_need_update = check_username_and_full_name(user_id, username, full_name, BotDB) - if is_need_update: - BotDB.update_username_and_full_name(user_id, username, full_name) - # Экранируем пользовательские данные для безопасного использования - safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" - safe_username = html.escape(username) if username else "Без никнейма" - - await message.answer( - f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}") - await message.bot.send_message(chat_id=GROUP_FOR_LOGS, - text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}') - await asyncio.sleep(1) - BotDB.update_date_for_user(date, user_id) - await state.set_state("START") - logger.info( - f"Формирование приветственного сообщения для пользователя. Сообщение: {message.text} " - f"Имя автора сообщения: {message.from_user.full_name})") - name_stick_hello = list(Path('Stick').rglob('Hello_*')) - random_stick_hello = random.choice(name_stick_hello) - random_stick_hello = FSInputFile(path=random_stick_hello) - logger.info(f"Стикер успешно получен из БД") - await message.answer_sticker(random_stick_hello) - await asyncio.sleep(0.3) - except Exception as e: - logger.error(f"Произошла ошибка handle_start_message при получении стикеров. Ошибка:{str(e)}") - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка при получении стикеров: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - try: - markup = get_reply_keyboard(BotDB, message.from_user.id) + # Send welcome message with metrics + markup = get_reply_keyboard(self.db, message.from_user.id) hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE') await message.answer(hello_message, reply_markup=markup, parse_mode='HTML') - except Exception as e: - logger.error( - f"Произошла ошибка при отправке приветственного сообщения для пользователя {message.from_user.id} Имя: {message.from_user.full_name}. Ошибка: {str(e)}") - await message.bot.send_message(IMPORTANT_LOGS, - f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - - -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - Command("restart") -) -async def restart_function(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) - full_name = message.from_user.full_name - username = message.from_user.username - user_id = message.from_user.id - # Проверяем наличие username для логирования - if not username: - # Экранируем full_name для безопасного использования - safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" - await message.bot.send_message(chat_id=GROUP_FOR_LOGS, - text=f'Пользователь {user_id} ({safe_full_name}) обратился к боту без username') - logger.warning(f"Пользователь {user_id} ({safe_full_name}) обратился к боту без username") - # Устанавливаем значение по умолчанию для username - username = "private_username" + @error_handler + async def suggest_post(self, message: types.Message, state: FSMContext, **kwargs): + """Handle suggest post button""" + # User service operations with metrics + await self.user_service.update_user_activity(message.from_user.id) + await self.user_service.log_user_message(message) + await state.set_state(FSM_STATES["SUGGEST"]) - markup = get_reply_keyboard(BotDB, message.from_user.id) - await message.answer(text='Я перезапущен!', - reply_markup=markup) - await state.set_state('START') - - -@private_router.message( - StateFilter("START"), - ChatTypeFilter(chat_type=["private"]), - F.text == '📢Предложить свой пост' -) -async def suggest_post(message: types.Message, state: FSMContext): - try: - user_id = message.from_user.id - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - BotDB.update_date_for_user(date, user_id) - await message.forward(chat_id=GROUP_FOR_LOGS) - await state.set_state("SUGGEST") - current_state = await state.get_state() - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Вызов функции suggest_post. Сообщение: {message.text} Имя автора сообщения: {safe_full_name} Идентификатор сообщения: {message.message_id}. State - {current_state}") markup = types.ReplyKeyboardRemove() suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS') - await message.answer(suggest_news) - await asyncio.sleep(0.3) - suggest_news_2 = messages.get_message(get_first_name(message), 'SUGGEST_NEWS_2') - await message.answer(suggest_news_2, reply_markup=markup) - except Exception as e: - await message.bot.send_message(IMPORTANT_LOGS, - f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - - -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - F.text == '👋🏼Сказать пока!' -) -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - F.text == 'Выйти из чата' -) -async def end_message(message: types.Message, state: FSMContext): - try: - user_id = message.from_user.id - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - BotDB.update_date_for_user(date, user_id) - await message.forward(chat_id=GROUP_FOR_LOGS) - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Вызов функции end_message. Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - name_stick_bye = list(Path('Stick').rglob('Universal_*')) - random_stick_bye = random.choice(name_stick_bye) - random_stick_bye = FSInputFile(path=random_stick_bye) - await message.answer_sticker(random_stick_bye) - except Exception as e: - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.error( - f"Ошибка в функции end_message при получении стикера: {str(e)} Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - try: + await message.answer(suggest_news, reply_markup=markup) + + @error_handler + async def end_message(self, message: types.Message, state: FSMContext, **kwargs): + """Handle goodbye button""" + # User service operations with metrics + await self.user_service.update_user_activity(message.from_user.id) + await self.user_service.log_user_message(message) + + # Send sticker + await self.sticker_service.send_random_goodbye_sticker(message) + + # Send goodbye message markup = types.ReplyKeyboardRemove() bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE') await message.answer(bye_message, reply_markup=markup) - await state.set_state("START") - except Exception as e: - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.error( - f"Ошибка в функции stickers при получении сообщения: {str(e)} Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") + await state.set_state(FSM_STATES["START"]) + + @error_handler + async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs): + """Handle post submission in suggest state""" + # Post service operations with metrics + await self.post_service.process_post(message, album) + + # Send success message and return to start state + markup_for_user = get_reply_keyboard(self.db, message.from_user.id) + success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE') + await message.answer(success_send_message, reply_markup=markup_for_user) + await state.set_state(FSM_STATES["START"]) + + @error_handler + async def stickers(self, message: types.Message, state: FSMContext, **kwargs): + """Handle stickers request""" + # User service operations with metrics + markup = get_reply_keyboard(self.db, message.from_user.id) + self.db.update_info_about_stickers(user_id=message.from_user.id) + await self.user_service.log_user_message(message) + await message.answer( + text=ERROR_MESSAGES["STICKERS_LINK"], + reply_markup=markup + ) + await state.set_state(FSM_STATES["START"]) + + @error_handler + async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs): + """Handle connect with admin button""" + # User service operations with metrics + await self.user_service.update_user_activity(message.from_user.id) + admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN') + await message.answer(admin_message, parse_mode="html") + await self.user_service.log_user_message(message) + await state.set_state(FSM_STATES["PRE_CHAT"]) + + @error_handler + async def resend_message_in_group_for_message(self, message: types.Message, state: FSMContext, **kwargs): + """Handle messages in admin chat states""" + # User service operations with metrics + await self.user_service.update_user_activity(message.from_user.id) + await message.forward(chat_id=self.settings.group_for_message) + + current_date = datetime.now() + date = current_date.strftime("%Y-%m-%d %H:%M:%S") + self.db.add_new_message_in_db(message.text, message.from_user.id, message.message_id + 1, date) + + question = messages.get_message(get_first_name(message), 'QUESTION') + user_state = await state.get_state() + + if user_state == FSM_STATES["PRE_CHAT"]: + markup = get_reply_keyboard(self.db, message.from_user.id) + await message.answer(question, reply_markup=markup) + await state.set_state(FSM_STATES["START"]) + elif user_state == FSM_STATES["CHAT"]: + markup = get_reply_keyboard_leave_chat() + await message.answer(question, reply_markup=markup) -@private_router.message( - StateFilter("SUGGEST"), - ChatTypeFilter(chat_type=["private"]), -) -async def suggest_router(message: types.Message, state: FSMContext, album: list = None): - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Вызов функции suggest_router. Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - first_name = get_first_name(message) - try: - post_caption = '' - if message.media_group_id is not None: - # Экранируем username для безопасного использования - safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма" - await send_text_message(GROUP_FOR_LOGS, message, - f'Закинул медиагруппу, пользователь: имя - {first_name}, ник - {safe_username}') - else: - await message.forward(chat_id=GROUP_FOR_LOGS) - if message.content_type == 'text': - lower_text = message.text.lower() - # Получаем текст сообщения и преобразовываем его по правилам - post_text = get_text_message(lower_text, first_name, - message.from_user.username) - # Получаем клавиатуру для поста - markup = get_reply_keyboard_for_post() - - # Отправляем сообщение в приватный канал - sent_message_id = await send_text_message(GROUP_FOR_POST, message, post_text, markup) - - # Записываем в базу пост - BotDB.add_post_in_db(sent_message_id, message.text, message.from_user.id) - - # Отправляем юзеру ответ, что сообщение отравлено и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.content_type == 'photo' and message.media_group_id is None: - if message.caption: - lower_caption = message.caption.lower() - # Получаем текст сообщения и преобразовываем его по правилам - post_caption = get_text_message(lower_caption, first_name, - message.from_user.username) - markup = get_reply_keyboard_for_post() - - # Отправляем фото и текст в приватный канал - sent_message = await send_photo_message(GROUP_FOR_POST, message, - message.photo[-1].file_id, post_caption, markup) - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message, BotDB) - - # Отправляем юзеру ответ и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.content_type == 'video' and message.media_group_id is None: - if message.caption: - lower_caption = message.caption.lower() - post_caption = get_text_message(lower_caption, first_name, - message.from_user.username) - markup = get_reply_keyboard_for_post() - # Получаем текст сообщения и преобразовываем его по правилам - - # Отправляем видео и текст в приватный канал - sent_message = await send_video_message(GROUP_FOR_POST, message, - message.video.file_id, post_caption, markup) - - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message, BotDB) - - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message) - - # Отправляем юзеру ответ и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.content_type == 'video_note' and message.media_group_id is None: - markup = get_reply_keyboard_for_post() - - # Отправляем видеокружок в приватный канал - sent_message = await send_video_note_message(GROUP_FOR_POST, message, - message.video_note.file_id, markup) - - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message, BotDB) - - # Отправляем юзеру ответ и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.content_type == 'audio' and message.media_group_id is None: - if message.caption: - lower_caption = message.caption.lower() - # Получаем текст сообщения и преобразовываем его по правилам - post_caption = get_text_message(lower_caption, first_name, - message.from_user.username) - markup = get_reply_keyboard_for_post() - - # Отправляем аудио и текст в приватный канал - sent_message = await send_audio_message(GROUP_FOR_POST, message, - message.audio.file_id, post_caption, markup) - - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message, BotDB) - - # Отправляем юзеру ответ и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.content_type == 'voice' and message.media_group_id is None: - markup = get_reply_keyboard_for_post() - - # Отправляем войс и текст в приватный канал - sent_message = await send_voice_message(GROUP_FOR_POST, message, - message.voice.file_id, markup) - - # Записываем в базу пост и контент - BotDB.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) - await add_in_db_media(sent_message, BotDB) - - # Отправляем юзеру ответ и возвращаем его в меню - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - - elif message.media_group_id is not None: - post_caption = " " - - # Получаем сообщение и проверяем есть ли подпись. Если подпись есть, то преобразуем ее через функцию - if album[0].caption: - lower_caption = album[0].caption.lower() - post_caption = get_text_message(lower_caption, first_name, - message.from_user.username) - - # Иначе обрабатываем фото и получаем медиагруппу - media_group = await prepare_media_group_from_middlewares(album, post_caption) - - # Отправляем медиагруппу в секретный чат - media_group_message_id = await send_media_group_message_to_private_chat(GROUP_FOR_POST, message, - media_group, BotDB) - await asyncio.sleep(0.2) - - # Получаем клавиатуру и отправляем еще одно текстовое сообщение с кнопками - markup = get_reply_keyboard_for_post() - help_message_id = await send_text_message(GROUP_FOR_POST, message, "^", markup) - - # Записываем в state идентификаторы текстового сообщения И последнего сообщения медиагруппы - BotDB.update_helper_message_in_db(message_id=media_group_message_id, helper_message_id=help_message_id) - - # Получаем клавиатуру для пользователя, благодарим за пост, и возвращаем в дефолтное сообщение - markup_for_user = get_reply_keyboard(BotDB, message.from_user.id) - success_send_message = messages.get_message(first_name, 'SUCCESS_SEND_MESSAGE') - await message.answer(success_send_message, reply_markup=markup_for_user) - await state.set_state("START") - else: - await message.bot.send_message(message.chat.id, - 'Я пока не умею работать с таким сообщением. ' - 'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n' - 'Мы добавим его к обработке если необходимо') - except Exception as e: - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") +# Factory function to create handlers with dependencies +def create_private_handlers(db, settings: BotSettings) -> PrivateHandlers: + """Create private handlers instance with dependencies""" + return PrivateHandlers(db, settings) -@private_router.message( - ChatTypeFilter(chat_type=["private"]), - F.text == '🤪Хочу стикеры' -) -async def stickers(message: types.Message, state: FSMContext): - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Вызов функции stickers. Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - markup = get_reply_keyboard(BotDB, message.from_user.id) - try: - BotDB.update_info_about_stickers(user_id=message.from_user.id) - await message.forward(chat_id=GROUP_FOR_LOGS) - await message.answer(text='Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk', - reply_markup=markup) - await state.set_state("START") - except Exception as e: - await message.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.error( - f"Ошибка функции stickers. Ошибка: {str(e)} Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") +# Legacy router for backward compatibility +private_router = Router() +# Initialize with global dependencies (for backward compatibility) +def init_legacy_router(): + """Initialize legacy router with global dependencies""" + global private_router + + from helper_bot.utils.base_dependency_factory import get_global_instance + + bdf = get_global_instance() + settings = BotSettings( + group_for_posts=bdf.settings['Telegram']['group_for_posts'], + group_for_message=bdf.settings['Telegram']['group_for_message'], + main_public=bdf.settings['Telegram']['main_public'], + group_for_logs=bdf.settings['Telegram']['group_for_logs'], + important_logs=bdf.settings['Telegram']['important_logs'], + preview_link=bdf.settings['Telegram']['preview_link'], + logs=bdf.settings['Settings']['logs'], + test=bdf.settings['Settings']['test'] + ) + + db = bdf.get_db() + handlers = create_private_handlers(db, settings) + + # Instead of trying to copy handlers, we'll use the new router directly + # This maintains backward compatibility while using the new architecture + private_router = handlers.router -@private_router.message( - StateFilter("START"), - ChatTypeFilter(chat_type=["private"]), - F.text == '📩Связаться с админами' -) -async def connect_with_admin(message: types.Message, state: FSMContext): - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Вызов функции connect_with_admin. Пользователь: {message.from_user.id} Имя автора сообщения: {safe_full_name}") - user_id = message.from_user.id - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - BotDB.update_date_for_user(date, user_id) - admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN') - await message.answer(admin_message, parse_mode="html") - await message.forward(chat_id=GROUP_FOR_LOGS) - await state.set_state("PRE_CHAT") - - -@private_router.message( - StateFilter("PRE_CHAT"), - ChatTypeFilter(chat_type=["private"]), -) -@private_router.message( - StateFilter("CHAT"), - ChatTypeFilter(chat_type=["private"]), -) -async def resend_message_in_group_for_message(message: types.Message, state: FSMContext): - user_id = message.from_user.id - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - BotDB.update_date_for_user(date, user_id) - # Экранируем full_name для безопасного использования в логах - safe_full_name = html.escape(message.from_user.full_name) if message.from_user.full_name else "Неизвестный пользователь" - logger.info( - f"Попытка пересылки сообщения в связь с админами. Сообщение: {message.text} Имя автора сообщения: {safe_full_name} Идентификатор сообщения: {message.message_id})") - await message.forward(chat_id=GROUP_FOR_MESSAGE) - current_date = datetime.now() - date = current_date.strftime("%Y-%m-%d %H:%M:%S") - BotDB.add_new_message_in_db(message.text, message.from_user.id, message.message_id + 1, date) - question = messages.get_message(get_first_name(message), 'QUESTION') - user_state = await state.get_state() - if user_state == "PRE_CHAT": - markup = get_reply_keyboard(BotDB, message.from_user.id) - await message.answer(question, reply_markup=markup) - await state.set_state("START") - elif user_state == "CHAT": - markup = get_reply_keyboard_leave_chat() - await message.answer(question, reply_markup=markup) +# Initialize legacy router +init_legacy_router() diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py new file mode 100644 index 0000000..7f3638c --- /dev/null +++ b/helper_bot/handlers/private/services.py @@ -0,0 +1,307 @@ +"""Service classes for private handlers""" + +# Standard library imports +import random +import asyncio +import html +from datetime import datetime +from pathlib import Path +from typing import Dict, Callable, Any, Protocol, Union +from dataclasses import dataclass + +# Third-party imports +from aiogram import types +from aiogram.types import FSInputFile + +# Local imports - utilities +from helper_bot.utils.helper_func import ( + get_first_name, + get_text_message, + send_text_message, + send_photo_message, + send_media_group_message_to_private_chat, + prepare_media_group_from_middlewares, + send_video_message, + send_video_note_message, + send_audio_message, + send_voice_message, + add_in_db_media, + check_username_and_full_name +) +from helper_bot.keyboards import get_reply_keyboard_for_post + +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors, + db_query_time +) + + +class DatabaseProtocol(Protocol): + """Protocol for database operations""" + def user_exists(self, user_id: int) -> bool: ... + def add_new_user_in_db(self, user_id: int, first_name: str, full_name: str, + username: str, is_bot: bool, language_code: str, + emoji: str, created_date: str, updated_date: str) -> None: ... + def update_username_and_full_name(self, user_id: int, username: str, full_name: str) -> None: ... + def update_date_for_user(self, date: str, user_id: int) -> None: ... + def add_post_in_db(self, message_id: int, text: str, user_id: int) -> None: ... + def update_info_about_stickers(self, user_id: int) -> None: ... + def add_new_message_in_db(self, text: str, user_id: int, message_id: int, date: str) -> None: ... + def update_helper_message_in_db(self, message_id: int, helper_message_id: int) -> None: ... + + +@dataclass +class BotSettings: + """Bot configuration settings""" + group_for_posts: str + group_for_message: str + main_public: str + group_for_logs: str + important_logs: str + preview_link: str + logs: str + test: str + + +class UserService: + """Service for user-related operations""" + + def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: + self.db = db + self.settings = settings + + @track_time("update_user_activity", "user_service") + @track_errors("user_service", "update_user_activity") + @db_query_time("update_user_activity", "users", "update") + async def update_user_activity(self, user_id: int) -> None: + """Update user's last activity timestamp with metrics tracking""" + current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.db.update_date_for_user(current_date, user_id) + + @track_time("ensure_user_exists", "user_service") + @track_errors("user_service", "ensure_user_exists") + async def ensure_user_exists(self, message: types.Message) -> None: + """Ensure user exists in database, create if needed with metrics tracking""" + user_id = message.from_user.id + full_name = message.from_user.full_name + username = message.from_user.username or "private_username" + first_name = get_first_name(message) + is_bot = message.from_user.is_bot + language_code = message.from_user.language_code + + current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if not self.db.user_exists(user_id): + # Record database operation + self.db.add_new_user_in_db( + user_id, first_name, full_name, username, is_bot, language_code, + "", current_date, current_date + ) + metrics.record_db_query("add_new_user", 0.0, "users", "insert") + else: + is_need_update = check_username_and_full_name(user_id, username, full_name, self.db) + if is_need_update: + self.db.update_username_and_full_name(user_id, username, full_name) + metrics.record_db_query("update_username_fullname", 0.0, "users", "update") + safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" + safe_username = html.escape(username) if username else "Без никнейма" + + await message.answer( + f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}") + await message.bot.send_message( + chat_id=self.settings.group_for_logs, + text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}') + + self.db.update_date_for_user(current_date, user_id) + metrics.record_db_query("update_date_for_user", 0.0, "users", "update") + + @track_time("log_user_message", "user_service") + @track_errors("user_service", "log_user_message") + async def log_user_message(self, message: types.Message) -> None: + """Forward user message to logs group with metrics tracking""" + await message.forward(chat_id=self.settings.group_for_logs) + + def get_safe_user_info(self, message: types.Message) -> tuple[str, str]: + """Get safely escaped user information for logging""" + full_name = message.from_user.full_name or "Неизвестный пользователь" + username = message.from_user.username or "Без никнейма" + return html.escape(full_name), html.escape(username) + + +class PostService: + """Service for post-related operations""" + + def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: + self.db = db + self.settings = settings + + @track_time("handle_text_post", "post_service") + @track_errors("post_service", "handle_text_post") + async def handle_text_post(self, message: types.Message, first_name: str) -> None: + """Handle text post submission""" + post_text = get_text_message(message.text.lower(), first_name, message.from_user.username) + markup = get_reply_keyboard_for_post() + + sent_message_id = await send_text_message(self.settings.group_for_posts, message, post_text, markup) + self.db.add_post_in_db(sent_message_id, message.text, message.from_user.id) + + @track_time("handle_photo_post", "post_service") + @track_errors("post_service", "handle_photo_post") + async def handle_photo_post(self, message: types.Message, first_name: str) -> None: + """Handle photo post submission""" + post_caption = "" + if message.caption: + post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username) + + markup = get_reply_keyboard_for_post() + sent_message = await send_photo_message( + self.settings.group_for_posts, message, message.photo[-1].file_id, post_caption, markup + ) + + self.db.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) + await add_in_db_media(sent_message, self.db) + + @track_time("handle_video_post", "post_service") + @track_errors("post_service", "handle_video_post") + async def handle_video_post(self, message: types.Message, first_name: str) -> None: + """Handle video post submission""" + post_caption = "" + if message.caption: + post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username) + + markup = get_reply_keyboard_for_post() + sent_message = await send_video_message( + self.settings.group_for_posts, message, message.video.file_id, post_caption, markup + ) + + self.db.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) + await add_in_db_media(sent_message, self.db) + + @track_time("handle_video_note_post", "post_service") + @track_errors("post_service", "handle_video_note_post") + async def handle_video_note_post(self, message: types.Message) -> None: + """Handle video note post submission""" + markup = get_reply_keyboard_for_post() + sent_message = await send_video_note_message( + self.settings.group_for_posts, message, message.video_note.file_id, markup + ) + + self.db.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) + await add_in_db_media(sent_message, self.db) + + @track_time("handle_audio_post", "post_service") + @track_errors("post_service", "handle_audio_post") + async def handle_audio_post(self, message: types.Message, first_name: str) -> None: + """Handle audio post submission""" + post_caption = "" + if message.caption: + post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username) + + markup = get_reply_keyboard_for_post() + sent_message = await send_audio_message( + self.settings.group_for_posts, message, message.audio.file_id, post_caption, markup + ) + + self.db.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) + await add_in_db_media(sent_message, self.db) + + @track_time("handle_voice_post", "post_service") + @track_errors("post_service", "handle_voice_post") + async def handle_voice_post(self, message: types.Message) -> None: + """Handle voice post submission""" + markup = get_reply_keyboard_for_post() + sent_message = await send_voice_message( + self.settings.group_for_posts, message, message.voice.file_id, markup + ) + + self.db.add_post_in_db(sent_message.message_id, sent_message.caption, message.from_user.id) + await add_in_db_media(sent_message, self.db) + + @track_time("handle_media_group_post", "post_service") + @track_errors("post_service", "handle_media_group_post") + async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None: + """Handle media group post submission""" + post_caption = " " + + if album and album[0].caption: + post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) + + media_group = await prepare_media_group_from_middlewares(album, post_caption) + media_group_message_id = await send_media_group_message_to_private_chat( + self.settings.group_for_posts, message, media_group, self.db + ) + + await asyncio.sleep(0.2) + + markup = get_reply_keyboard_for_post() + help_message_id = await send_text_message(self.settings.group_for_posts, message, "^", markup) + + self.db.update_helper_message_in_db( + message_id=media_group_message_id, helper_message_id=help_message_id + ) + + @track_time("process_post", "post_service") + @track_errors("post_service", "process_post") + async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None: + """Process post based on content type""" + first_name = get_first_name(message) + + if message.media_group_id is not None: + safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма" + await send_text_message( + self.settings.group_for_logs, message, + f'Закинул медиагруппу, пользователь: имя - {first_name}, ник - {safe_username}' + ) + await self.handle_media_group_post(message, album, first_name) + return + + content_handlers: Dict[str, Callable] = { + 'text': lambda: self.handle_text_post(message, first_name), + 'photo': lambda: self.handle_photo_post(message, first_name), + 'video': lambda: self.handle_video_post(message, first_name), + 'video_note': lambda: self.handle_video_note_post(message), + 'audio': lambda: self.handle_audio_post(message, first_name), + 'voice': lambda: self.handle_voice_post(message) + } + + handler = content_handlers.get(message.content_type) + if handler: + await handler() + else: + from .constants import ERROR_MESSAGES + await message.bot.send_message( + message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"] + ) + + +class StickerService: + """Service for sticker-related operations""" + + def __init__(self, settings: BotSettings) -> None: + self.settings = settings + + @track_time("send_random_hello_sticker", "sticker_service") + @track_errors("sticker_service", "send_random_hello_sticker") + async def send_random_hello_sticker(self, message: types.Message) -> None: + """Send random hello sticker with metrics tracking""" + name_stick_hello = list(Path('Stick').rglob('Hello_*')) + if not name_stick_hello: + return + random_stick_hello = random.choice(name_stick_hello) + random_stick_hello = FSInputFile(path=random_stick_hello) + await message.answer_sticker(random_stick_hello) + await asyncio.sleep(0.3) + + @track_time("send_random_goodbye_sticker", "sticker_service") + @track_errors("sticker_service", "send_random_goodbye_sticker") + async def send_random_goodbye_sticker(self, message: types.Message) -> None: + """Send random goodbye sticker with metrics tracking""" + name_stick_bye = list(Path('Stick').rglob('Universal_*')) + if not name_stick_bye: + return + random_stick_bye = random.choice(name_stick_bye) + random_stick_bye = FSInputFile(path=random_stick_bye) + await message.answer_sticker(random_stick_bye) diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py index 5506e1c..714ffcb 100644 --- a/helper_bot/keyboards/keyboards.py +++ b/helper_bot/keyboards/keyboards.py @@ -1,26 +1,37 @@ from aiogram import types from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors +) + def get_reply_keyboard_for_post(): builder = InlineKeyboardBuilder() builder.row(types.InlineKeyboardButton( - text="Опубликовать", callback_data="publish") + text="Опубликовать", callback_data="publish"), + types.InlineKeyboardButton( + text="Отклонить", callback_data="decline") ) builder.row(types.InlineKeyboardButton( - text="Отклонить", callback_data="decline") + text="👮‍♂️ Забанить", callback_data="ban") ) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) return markup +@track_time("get_reply_keyboard", "keyboard_service") +@track_errors("keyboard_service", "get_reply_keyboard") def get_reply_keyboard(BotDB, user_id): builder = ReplyKeyboardBuilder() - builder.add(types.KeyboardButton(text="📢Предложить свой пост")) - builder.add(types.KeyboardButton(text="📩Связаться с админами")) - builder.add(types.KeyboardButton(text="👋🏼Сказать пока!")) + builder.row(types.KeyboardButton(text="📢Предложить свой пост")) + builder.row(types.KeyboardButton(text="📩Связаться с админами")) + builder.row(types.KeyboardButton(text="👋🏼Сказать пока!")) if not BotDB.get_info_about_stickers(user_id=user_id): - builder.add(types.KeyboardButton(text="🤪Хочу стикеры")) + builder.row(types.KeyboardButton(text="🤪Хочу стикеры")) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) return markup @@ -34,22 +45,25 @@ def get_reply_keyboard_leave_chat(): def get_reply_keyboard_admin(): builder = ReplyKeyboardBuilder() - builder.add(types.KeyboardButton(text="Бан (Список)")) - builder.add(types.KeyboardButton(text="Бан по нику")) - builder.add(types.KeyboardButton(text="Бан по ID")) - builder.add(types.KeyboardButton(text="Тестовый бан")) - builder.add(types.KeyboardButton(text="Разбан (список)")) - builder.add(types.KeyboardButton(text="Вернуться в бота")) + builder.row( + types.KeyboardButton(text="Бан (Список)"), + types.KeyboardButton(text="Бан по нику"), + types.KeyboardButton(text="Бан по ID") + ) + builder.row( + types.KeyboardButton(text="Разбан (список)"), + types.KeyboardButton(text="Вернуться в бота") + ) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) return markup -def create_keyboard_with_pagination(page: int, total_items: int, array_items: list[tuple[any, any]], callback: str): +def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str): """ Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback Args: - page: Номер текущей страницы. + page: Номер текущей страницы (начинается с 1). total_items: Общее количество элементов. array_items: Лист кортежей. Содержит в себе user_name: user_id callback: Действие в коллбеке. Вернет callback вида ({callback}_{user_id}) @@ -57,34 +71,75 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li Returns: InlineKeyboardMarkup: Клавиатура с кнопками пагинации. """ + + # Проверяем валидность входных данных + if page < 1: + page = 1 + if not array_items: + # Если нет элементов, возвращаем только кнопку "Назад" + keyboard = InlineKeyboardBuilder() + home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return") + keyboard.row(home_button) + return keyboard.as_markup() # Определяем общее количество страниц - total_pages = (total_items + 9 - 1) // 9 + items_per_page = 9 + total_pages = (total_items + items_per_page - 1) // items_per_page + + # Ограничиваем страницу максимальным значением + if page > total_pages: + page = total_pages # Создаем билдер для клавиатуры keyboard = InlineKeyboardBuilder() + # Вычисляем стартовый номер для текущей страницы - start_index = (page - 1) * 9 - - # Кнопки с номерами страниц - for i in range(start_index, min(start_index + 9, len(array_items))): - keyboard.add(types.InlineKeyboardButton( + start_index = (page - 1) * items_per_page + + # Кнопки с элементами текущей страницы + end_index = min(start_index + items_per_page, len(array_items)) + current_row = [] + + for i in range(start_index, end_index): + current_row.append(types.InlineKeyboardButton( text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}" )) - keyboard.adjust(3) - - next_button = types.InlineKeyboardButton( - text="➡️ Следующая", callback_data=f"page_{page + 1}" - ) - prev_button = types.InlineKeyboardButton( - text="⬅️ Предыдущая", callback_data=f"page_{page - 1}" - ) - keyboard.row(prev_button, next_button) - home_button = types.InlineKeyboardButton( - text="🏠 Назад", callback_data="return") + + # Когда набирается 3 кнопки, добавляем ряд + if len(current_row) == 3: + keyboard.row(*current_row) + current_row = [] + + # Добавляем оставшиеся кнопки, если они есть + if current_row: + keyboard.row(*current_row) + + # Создаем кнопки навигации только если нужно + navigation_buttons = [] + + # Кнопка "Предыдущая" - показываем только если не первая страница + if page > 1: + prev_button = types.InlineKeyboardButton( + text="⬅️ Предыдущая", callback_data=f"page_{page - 1}" + ) + navigation_buttons.append(prev_button) + + # Кнопка "Следующая" - показываем только если не последняя страница + if page < total_pages: + next_button = types.InlineKeyboardButton( + text="➡️ Следующая", callback_data=f"page_{page + 1}" + ) + navigation_buttons.append(next_button) + + # Добавляем кнопки навигации, если они есть + if navigation_buttons: + keyboard.row(*navigation_buttons) + + # Кнопка "Назад" + home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return") keyboard.row(home_button) - k = keyboard.as_markup() - return k + + return keyboard.as_markup() def create_keyboard_for_ban_reason(): diff --git a/helper_bot/main.py b/helper_bot/main.py index d9d3763..da740dd 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -7,6 +7,9 @@ from helper_bot.handlers.admin import admin_router from helper_bot.handlers.callback import callback_router from helper_bot.handlers.group import group_router from helper_bot.handlers.private import private_router +from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware +from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware +from helper_bot.middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware async def start_bot(bdf): @@ -14,8 +17,20 @@ async def start_bot(bdf): bot = Bot(token=token, default=DefaultBotProperties( parse_mode='HTML', link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] - ), timeout=30.0) # Добавляем таймаут для предотвращения зависаний + ), timeout=30.0) dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) - dp.include_routers(private_router, callback_router, group_router, admin_router) + + # ✅ Оптимизированная регистрация middleware + dp.update.outer_middleware(DependenciesMiddleware()) + dp.update.outer_middleware(MetricsMiddleware()) + dp.update.outer_middleware(BlacklistMiddleware()) + + # Добавляем middleware напрямую к роутерам для тестирования + admin_router.message.middleware(MetricsMiddleware()) + private_router.message.middleware(MetricsMiddleware()) + callback_router.callback_query.middleware(MetricsMiddleware()) + group_router.message.middleware(MetricsMiddleware()) + + dp.include_routers(admin_router, private_router, callback_router, group_router) await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, skip_updates=True) diff --git a/helper_bot/middlewares/blacklist_middleware.py b/helper_bot/middlewares/blacklist_middleware.py index e8e8530..18e82f3 100644 --- a/helper_bot/middlewares/blacklist_middleware.py +++ b/helper_bot/middlewares/blacklist_middleware.py @@ -2,6 +2,7 @@ from typing import Dict, Any import html from aiogram import BaseMiddleware, types +from aiogram.types import TelegramObject, Message, CallbackQuery from helper_bot.utils.base_dependency_factory import get_global_instance from logs.custom_logger import logger @@ -10,17 +11,38 @@ BotDB = bdf.get_db() class BlacklistMiddleware(BaseMiddleware): - async def __call__(self, handler, event: types.Message, data: Dict[str, Any]) -> Any: - logger.info(f'Вызов BlacklistMiddleware для пользователя {event.from_user.username}') + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + # Проверяем тип события и получаем пользователя + user = None + if isinstance(event, Message): + user = event.from_user + elif isinstance(event, CallbackQuery): + user = event.from_user + + # Если это не сообщение или callback, пропускаем проверку + if not user: + return await handler(event, data) + + logger.info(f'Вызов BlacklistMiddleware для пользователя {user.username}') + # Используем асинхронную версию для предотвращения блокировки - if await BotDB.check_user_in_blacklist_async(user_id=event.from_user.id): - logger.info(f'BlacklistMiddleware результат для пользователя: {event.from_user.username} заблокирован!') - user_info = await BotDB.get_blacklist_users_by_id_async(event.from_user.id) + if await BotDB.check_user_in_blacklist_async(user_id=user.id): + logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!') + user_info = await BotDB.get_blacklist_users_by_id_async(user.id) # Экранируем потенциально проблемные символы reason = html.escape(str(user_info[2])) if user_info[2] else "Не указана" date_unban = html.escape(str(user_info[3])) if user_info[3] else "Не указана" - await event.answer( - f"Ты заблокирован.\nПричина блокировки: {reason}\nДата разбана: {date_unban}") + + # Отправляем сообщение в зависимости от типа события + if isinstance(event, Message): + await event.answer( + f"Ты заблокирован.\nПричина блокировки: {reason}\nДата разбана: {date_unban}") + elif isinstance(event, CallbackQuery): + await event.answer( + f"Ты заблокирован.\nПричина блокировки: {reason}\nДата разбана: {date_unban}", + show_alert=True) + return False - logger.info(f'BlacklistMiddleware результат для пользователя: {event.from_user.username} доступ разрешен') + + logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен') return await handler(event, data) diff --git a/helper_bot/middlewares/dependencies_middleware.py b/helper_bot/middlewares/dependencies_middleware.py new file mode 100644 index 0000000..3a9f3de --- /dev/null +++ b/helper_bot/middlewares/dependencies_middleware.py @@ -0,0 +1,31 @@ +from typing import Any, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from helper_bot.utils.base_dependency_factory import get_global_instance +from logs.custom_logger import logger + + +class DependenciesMiddleware(BaseMiddleware): + """Универсальная middleware для внедрения зависимостей во все хендлеры""" + + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + try: + # Получаем глобальные зависимости + bdf = get_global_instance() + + # Внедряем зависимости в data для MagicData + if 'bot_db' not in data: + data['bot_db'] = bdf.get_db() + if 'settings' not in data: + data['settings'] = bdf.settings + data['bot'] = data.get('bot') + data['dp'] = data.get('dp') + + logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}") + + except Exception as e: + logger.error(f"Ошибка в DependenciesMiddleware: {e}") + # Не прерываем выполнение, продолжаем без зависимостей + + return await handler(event, data) diff --git a/helper_bot/middlewares/metrics_middleware.py b/helper_bot/middlewares/metrics_middleware.py new file mode 100644 index 0000000..202f624 --- /dev/null +++ b/helper_bot/middlewares/metrics_middleware.py @@ -0,0 +1,213 @@ +""" +Metrics middleware for aiogram 3.x. +Automatically collects metrics for message processing, command execution, and errors. +""" + +from typing import Any, Awaitable, Callable, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery +from aiogram.enums import ChatType +import time +import logging +from ..utils.metrics import metrics + + +class MetricsMiddleware(BaseMiddleware): + """Middleware for automatic metrics collection in aiogram handlers.""" + + def __init__(self): + super().__init__() + self.logger = logging.getLogger(__name__) + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Process event and collect metrics.""" + + # Добавляем логирование для диагностики + self.logger.info(f"📊 MetricsMiddleware called for event type: {type(event).__name__}") + + # Extract command info before execution + command_info = None + if isinstance(event, Message): + self.logger.info(f"📊 Processing Message event") + await self._record_message_metrics(event) + if event.text and event.text.startswith('/'): + command_info = { + 'command': event.text.split()[0][1:], # Remove '/' and get command name + 'user_type': "user" if event.from_user else "unknown", + 'handler_type': "message_handler" + } + elif isinstance(event, CallbackQuery): + self.logger.info(f"📊 Processing CallbackQuery event") + await self._record_callback_metrics(event) + if event.data: + parts = event.data.split(':', 1) + if parts: + command_info = { + 'command': parts[0], + 'user_type': "user" if event.from_user else "unknown", + 'handler_type': "callback_handler" + } + else: + self.logger.info(f"📊 Processing unknown event type: {type(event).__name__}") + + # Execute handler with timing + start_time = time.time() + try: + result = await handler(event, data) + duration = time.time() - start_time + + # Record successful execution + handler_name = self._get_handler_name(handler) + self.logger.info(f"📊 Recording successful execution: {handler_name}") + metrics.record_method_duration( + handler_name, + duration, + "handler", + "success" + ) + + # Record command with success status if applicable + if command_info: + metrics.record_command( + command_info['command'], + command_info['handler_type'], + command_info['user_type'], + "success" + ) + + return result + + except Exception as e: + duration = time.time() - start_time + + # Record error and timing + handler_name = self._get_handler_name(handler) + self.logger.error(f"📊 Recording error execution: {handler_name}, error: {type(e).__name__}") + metrics.record_method_duration( + handler_name, + duration, + "handler", + "error" + ) + metrics.record_error( + type(e).__name__, + "handler", + handler_name + ) + + # Record command with error status if applicable + if command_info: + metrics.record_command( + command_info['command'], + command_info['handler_type'], + command_info['user_type'], + "error" + ) + + raise + + def _get_handler_name(self, handler: Callable) -> str: + """Extract handler name efficiently.""" + # Проверяем различные способы получения имени хендлера + if hasattr(handler, '__name__') and handler.__name__ != '': + return handler.__name__ + elif hasattr(handler, '__qualname__') and handler.__qualname__ != '': + return handler.__qualname__ + elif hasattr(handler, 'callback') and hasattr(handler.callback, '__name__'): + return handler.callback.__name__ + elif hasattr(handler, 'view') and hasattr(handler.view, '__name__'): + return handler.view.__name__ + else: + # Пытаемся получить имя из строкового представления + handler_str = str(handler) + if 'function' in handler_str: + # Извлекаем имя функции из строки + import re + match = re.search(r'function\s+(\w+)', handler_str) + if match: + return match.group(1) + return "unknown" + + async def _record_message_metrics(self, message: Message): + """Record message metrics efficiently.""" + # Determine message type + message_type = "text" + if message.photo: + message_type = "photo" + elif message.video: + message_type = "video" + elif message.audio: + message_type = "audio" + elif message.document: + message_type = "document" + elif message.voice: + message_type = "voice" + elif message.sticker: + message_type = "sticker" + elif message.animation: + message_type = "animation" + + # Determine chat type + chat_type = "private" + if message.chat.type == ChatType.GROUP: + chat_type = "group" + elif message.chat.type == ChatType.SUPERGROUP: + chat_type = "supergroup" + elif message.chat.type == ChatType.CHANNEL: + chat_type = "channel" + + # Record message processing + metrics.record_message(message_type, chat_type, "message_handler") + + async def _record_callback_metrics(self, callback: CallbackQuery): + """Record callback metrics efficiently.""" + metrics.record_message("callback_query", "callback", "callback_handler") + + +class DatabaseMetricsMiddleware(BaseMiddleware): + """Middleware for database operation metrics.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Process event and collect database metrics.""" + + # Check if this handler involves database operations + handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown" + + # You can add specific database operation detection logic here + # For now, we'll just pass through and let individual decorators handle it + + return await handler(event, data) + + +class ErrorMetricsMiddleware(BaseMiddleware): + """Middleware for error tracking and metrics.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """Process event and collect error metrics.""" + + try: + return await handler(event, data) + except Exception as e: + # Record error metrics + handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown" + metrics.record_error( + type(e).__name__, + "handler", + handler_name + ) + raise diff --git a/helper_bot/server_monitor.py b/helper_bot/server_monitor.py new file mode 100644 index 0000000..d568b2c --- /dev/null +++ b/helper_bot/server_monitor.py @@ -0,0 +1,623 @@ +import asyncio +import os +import psutil +import time +import platform +from datetime import datetime, timedelta +from typing import Dict, Optional, Tuple +import logging + +logger = logging.getLogger(__name__) + + +class ServerMonitor: + def __init__(self, bot, group_for_logs: str, important_logs: str): + self.bot = bot + self.group_for_logs = group_for_logs + self.important_logs = important_logs + + # Определяем ОС + self.os_type = self._detect_os() + logger.info(f"Обнаружена ОС: {self.os_type}") + + # Пороговые значения для алертов + self.threshold = 80.0 + self.recovery_threshold = 75.0 + + # Состояние алертов для предотвращения спама + self.alert_states = { + 'cpu': False, + 'ram': False, + 'disk': False + } + + # PID файлы для отслеживания процессов + self.pid_files = { + 'voice_bot': 'voice_bot.pid', + 'helper_bot': 'helper_bot.pid' + } + + # Время последней отправки статуса + self.last_status_time = None + + # Для расчета скорости диска + self.last_disk_io = None + self.last_disk_io_time = None + + # Время запуска бота для расчета uptime + self.bot_start_time = time.time() + + def _detect_os(self) -> str: + """Определение типа операционной системы""" + system = platform.system().lower() + if system == "darwin": + return "macos" + elif system == "linux": + return "ubuntu" + else: + return "unknown" + + def _get_disk_path(self) -> str: + """Получение пути к диску в зависимости от ОС""" + if self.os_type == "macos": + return "/" + elif self.os_type == "ubuntu": + return "/" + else: + return "/" + + def _get_disk_usage(self) -> Optional[object]: + """Получение информации о диске с учетом ОС""" + try: + if self.os_type == "macos": + # На macOS используем diskutil для получения реального использования диска + return self._get_macos_disk_usage() + else: + disk_path = self._get_disk_path() + return psutil.disk_usage(disk_path) + except Exception as e: + logger.error(f"Ошибка при получении информации о диске: {e}") + return None + + def _get_macos_disk_usage(self) -> Optional[object]: + """Получение информации о диске на macOS через diskutil""" + try: + import subprocess + import re + + # Получаем информацию о диске через diskutil + result = subprocess.run(['diskutil', 'info', '/'], capture_output=True, text=True) + if result.returncode != 0: + # Fallback к psutil + return psutil.disk_usage('/') + + output = result.stdout + + # Извлекаем размеры из вывода diskutil + total_match = re.search(r'Container Total Space:\s+(\d+\.\d+)\s+GB', output) + free_match = re.search(r'Container Free Space:\s+(\d+\.\d+)\s+GB', output) + + if total_match and free_match: + total_gb = float(total_match.group(1)) + free_gb = float(free_match.group(1)) + used_gb = total_gb - free_gb + + # Создаем объект, похожий на результат psutil.disk_usage + class DiskUsage: + def __init__(self, total, used, free): + self.total = total * (1024**3) # Конвертируем в байты + self.used = used * (1024**3) + self.free = free * (1024**3) + + return DiskUsage(total_gb, used_gb, free_gb) + else: + # Fallback к psutil + return psutil.disk_usage('/') + + except Exception as e: + logger.error(f"Ошибка при получении информации о диске macOS: {e}") + # Fallback к psutil + return psutil.disk_usage('/') + + def _get_disk_io_counters(self): + """Получение статистики диска с учетом ОС""" + try: + if self.os_type == "macos": + # На macOS может быть несколько дисков, берем основной + return psutil.disk_io_counters(perdisk=False) + elif self.os_type == "ubuntu": + # На Ubuntu обычно один диск + return psutil.disk_io_counters(perdisk=False) + else: + return psutil.disk_io_counters() + except Exception as e: + logger.error(f"Ошибка при получении статистики диска: {e}") + return None + + def _get_system_uptime(self) -> float: + """Получение uptime системы с учетом ОС""" + try: + if self.os_type == "macos": + # На macOS используем boot_time + boot_time = psutil.boot_time() + return time.time() - boot_time + elif self.os_type == "ubuntu": + # На Ubuntu также используем boot_time + boot_time = psutil.boot_time() + return time.time() - boot_time + else: + boot_time = psutil.boot_time() + return time.time() - boot_time + except Exception as e: + logger.error(f"Ошибка при получении uptime системы: {e}") + return 0.0 + + def get_bot_uptime(self) -> str: + """Получение uptime бота""" + uptime_seconds = time.time() - self.bot_start_time + return self._format_uptime(uptime_seconds) + + def get_system_info(self) -> Dict: + """Получение информации о системе""" + try: + # CPU + cpu_percent = psutil.cpu_percent(interval=1) + load_avg = psutil.getloadavg() + cpu_count = psutil.cpu_count() + + # Память + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + + # Используем единый расчет для всех ОС: used / total для получения процента занятой памяти + # Это обеспечивает консистентность между macOS и Ubuntu + ram_percent = (memory.used / memory.total) * 100 + + # Диск + disk = self._get_disk_usage() + disk_io = self._get_disk_io_counters() + + if disk is None: + logger.error("Не удалось получить информацию о диске") + return {} + + # Расчет скорости диска + disk_read_speed, disk_write_speed = self._calculate_disk_speed(disk_io) + + # Система + system_uptime = self._get_system_uptime() + + # Получаем имя хоста в зависимости от ОС + if self.os_type == "macos": + hostname = os.uname().nodename + elif self.os_type == "ubuntu": + hostname = os.uname().nodename + else: + hostname = "unknown" + + return { + 'cpu_percent': cpu_percent, + 'load_avg_1m': round(load_avg[0], 2), + 'load_avg_5m': round(load_avg[1], 2), + 'load_avg_15m': round(load_avg[2], 2), + 'cpu_count': cpu_count, + 'ram_used': round(memory.used / (1024**3), 2), + 'ram_total': round(memory.total / (1024**3), 2), + 'ram_percent': round(ram_percent, 1), # Исправленный процент занятой памяти + 'swap_used': round(swap.used / (1024**3), 2), + 'swap_total': round(swap.total / (1024**3), 2), + 'swap_percent': swap.percent, + 'disk_used': round(disk.used / (1024**3), 2), + 'disk_total': round(disk.total / (1024**3), 2), + 'disk_percent': round((disk.used / disk.total) * 100, 1), + 'disk_free': round(disk.free / (1024**3), 2), + 'disk_read_speed': disk_read_speed, + 'disk_write_speed': disk_write_speed, + 'disk_io_percent': self._calculate_disk_io_percent(), + 'system_uptime': self._format_uptime(system_uptime), + 'bot_uptime': self.get_bot_uptime(), + 'server_hostname': hostname, + 'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + except Exception as e: + logger.error(f"Ошибка при получении информации о системе: {e}") + return {} + + def _get_disk_space_emoji(self, disk_percent: float) -> str: + """Получение эмодзи для дискового пространства""" + if disk_percent < 60: + return "🟢" + elif disk_percent < 90: + return "⚠️" + else: + return "🚨" + + def _format_bytes(self, bytes_value: int) -> str: + """Форматирование байтов в человекочитаемый вид""" + if bytes_value == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while bytes_value >= 1024 and i < len(size_names) - 1: + bytes_value /= 1024.0 + i += 1 + + return f"{bytes_value:.1f} {size_names[i]}" + + def _format_uptime(self, seconds: float) -> str: + """Форматирование времени работы системы""" + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + + if days > 0: + return f"{days}д {hours}ч {minutes}м" + elif hours > 0: + return f"{hours}ч {minutes}м" + else: + return f"{minutes}м" + + def check_process_status(self, process_name: str) -> Tuple[str, str]: + """Проверка статуса процесса и возврат статуса с uptime""" + try: + # Сначала проверяем по PID файлу + pid_file = self.pid_files.get(process_name) + if pid_file and os.path.exists(pid_file): + try: + with open(pid_file, 'r') as f: + content = f.read().strip() + if content and content != '# Этот файл будет автоматически обновляться при запуске бота': + pid = int(content) + if psutil.pid_exists(pid): + # Получаем uptime процесса + try: + proc = psutil.Process(pid) + proc_uptime = time.time() - proc.create_time() + uptime_str = self._format_uptime(proc_uptime) + return "✅", f"Uptime {uptime_str}" + except: + return "✅", "Uptime неизвестно" + except (ValueError, FileNotFoundError): + pass + + # Проверяем по имени процесса более точно + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + proc_name = proc.info['name'].lower() + cmdline = ' '.join(proc.info['cmdline']).lower() if proc.info['cmdline'] else '' + + # Более точная проверка для каждого бота + if process_name == 'voice_bot': + # Проверяем voice_bot + if ('voice_bot' in proc_name or + 'voice_bot' in cmdline or + 'voice_bot_v2.py' in cmdline): + # Получаем uptime процесса + try: + proc_uptime = time.time() - proc.create_time() + uptime_str = self._format_uptime(proc_uptime) + return "✅", f"Uptime {uptime_str}" + except: + return "✅", "Uptime неизвестно" + elif process_name == 'helper_bot': + # Проверяем helper_bot + if ('helper_bot' in proc_name or + 'helper_bot' in cmdline or + 'run_helper.py' in cmdline or + 'python' in proc_name and 'helper_bot' in cmdline): + # Получаем uptime процесса + try: + proc_uptime = time.time() - proc.create_time() + uptime_str = self._format_uptime(proc_uptime) + return "✅", f"Uptime {uptime_str}" + except: + return "✅", "Uptime неизвестно" + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + return "❌", "Выключен" + except Exception as e: + logger.error(f"Ошибка при проверке процесса {process_name}: {e}") + return "❌", "Выключен" + + def should_send_status(self) -> bool: + """Проверка, нужно ли отправить статус (каждые 30 минут в 00 и 30 минут часа)""" + now = datetime.now() + + # Проверяем, что сейчас 00 или 30 минут часа + if now.minute in [0, 30]: + # Проверяем, не отправляли ли мы уже статус в эту минуту + if (self.last_status_time is None or + self.last_status_time.hour != now.hour or + self.last_status_time.minute != now.minute): + self.last_status_time = now + return True + + return False + + def _calculate_disk_speed(self, current_disk_io) -> Tuple[str, str]: + """Расчет скорости чтения/записи диска""" + current_time = time.time() + + if self.last_disk_io is None or self.last_disk_io_time is None: + self.last_disk_io = current_disk_io + self.last_disk_io_time = current_time + return "0 B/s", "0 B/s" + + time_diff = current_time - self.last_disk_io_time + if time_diff < 1: # Минимальный интервал 1 секунда + return "0 B/s", "0 B/s" + + read_diff = current_disk_io.read_bytes - self.last_disk_io.read_bytes + write_diff = current_disk_io.write_bytes - self.last_disk_io.write_bytes + + read_speed = read_diff / time_diff + write_speed = write_diff / time_diff + + # Обновляем предыдущие значения + self.last_disk_io = current_disk_io + self.last_disk_io_time = current_time + + return self._format_bytes(read_speed) + "/s", self._format_bytes(write_speed) + "/s" + + def _calculate_disk_io_percent(self) -> int: + """Расчет процента загрузки диска на основе IOPS""" + try: + # Получаем статистику диска + disk_io = self._get_disk_io_counters() + if disk_io is None: + return 0 + + # Простая эвристика: считаем общее количество операций + total_ops = disk_io.read_count + disk_io.write_count + + # Нормализуем к проценту (это приблизительная оценка) + # На macOS обычно нормальная нагрузка до 1000-5000 операций в секунду + if total_ops < 1000: + return 10 + elif total_ops < 5000: + return 30 + elif total_ops < 10000: + return 50 + elif total_ops < 20000: + return 70 + else: + return 90 + except: + return 0 + + def should_send_startup_status(self) -> bool: + """Проверка, нужно ли отправить статус при запуске""" + return self.last_status_time is None + + async def send_startup_message(self): + """Отправка сообщения о запуске бота""" + try: + message = f"""🚀 **Бот запущен!** +--------------------------------- +**Время запуска:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**Сервер:** `{psutil.os.uname().nodename}` +**Система:** {psutil.os.uname().sysname} {psutil.os.uname().release} +**ОС:** {self.os_type.upper()} + +✅ Мониторинг сервера активирован +✅ Статус будет отправляться каждые 30 минут (в 00 и 30 минут часа) +✅ Алерты будут отправляться при превышении пороговых значений +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Сообщение о запуске бота отправлено") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о запуске: {e}") + + async def send_shutdown_message(self): + """Отправка сообщения об отключении бота""" + try: + # Получаем финальную информацию о системе + system_info = self.get_system_info() + if not system_info: + system_info = { + 'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'server_hostname': psutil.os.uname().nodename + } + + message = f"""🛑 **Бот отключен!** +--------------------------------- +**Время отключения:** {system_info['current_time']} +**Сервер:** `{system_info['server_hostname']}` + +❌ Мониторинг сервера остановлен +❌ Статус больше не будет отправляться +❌ Алерты отключены + +⚠️ **Внимание:** Проверьте состояние сервера! +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Сообщение об отключении бота отправлено") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения об отключении: {e}") + + def check_alerts(self, system_info: Dict) -> Tuple[bool, Optional[str]]: + """Проверка необходимости отправки алертов""" + alerts = [] + + # Проверка CPU + if system_info['cpu_percent'] > self.threshold and not self.alert_states['cpu']: + self.alert_states['cpu'] = True + alerts.append(('cpu', system_info['cpu_percent'], f"Нагрузка за 1 мин: {system_info['load_avg_1m']}")) + + # Проверка RAM + if system_info['ram_percent'] > self.threshold and not self.alert_states['ram']: + self.alert_states['ram'] = True + alerts.append(('ram', system_info['ram_percent'], f"Используется: {system_info['ram_used']} GB из {system_info['ram_total']} GB")) + + # Проверка диска + if system_info['disk_percent'] > self.threshold and not self.alert_states['disk']: + self.alert_states['disk'] = True + alerts.append(('disk', system_info['disk_percent'], f"Свободно: {system_info['disk_free']} GB на /")) + + # Проверка восстановления + recoveries = [] + if system_info['cpu_percent'] < self.recovery_threshold and self.alert_states['cpu']: + self.alert_states['cpu'] = False + recoveries.append(('cpu', system_info['cpu_percent'])) + + if system_info['ram_percent'] < self.recovery_threshold and self.alert_states['ram']: + self.alert_states['ram'] = False + recoveries.append(('ram', system_info['ram_percent'])) + + if system_info['disk_percent'] < self.recovery_threshold and self.alert_states['disk']: + self.alert_states['disk'] = False + recoveries.append(('disk', system_info['disk_percent'])) + + return alerts, recoveries + + async def send_status_message(self, system_info: Dict): + """Отправка сообщения со статусом сервера""" + try: + voice_bot_status, voice_bot_uptime = self.check_process_status('voice_bot') + helper_bot_status, helper_bot_uptime = self.check_process_status('helper_bot') + + # Получаем эмодзи для дискового пространства + disk_emoji = self._get_disk_space_emoji(system_info['disk_percent']) + + message = f"""🖥 **Статус Сервера** | {system_info['current_time']} +--------------------------------- +**📊 Общая нагрузка:** +CPU: {system_info['cpu_percent']}% | LA: {system_info['load_avg_1m']} / {system_info['cpu_count']} | IO Wait: {system_info['disk_percent']}% + +**💾 Память:** +RAM: {system_info['ram_used']}/{system_info['ram_total']} GB ({system_info['ram_percent']}%) +Swap: {system_info['swap_used']}/{system_info['swap_total']} GB ({system_info['swap_percent']}%) + +**🗂️ Дисковое пространство:** +Диск (/): {system_info['disk_used']}/{system_info['disk_total']} GB ({system_info['disk_percent']}%) {disk_emoji} + +**💿 Диск I/O:** +Read: {system_info['disk_read_speed']} | Write: {system_info['disk_write_speed']} +Диск загружен: {system_info['disk_io_percent']}% + +**🤖 Процессы:** +{voice_bot_status} voice-bot - {voice_bot_uptime} +{helper_bot_status} helper-bot - {helper_bot_uptime} +--------------------------------- +⏰ Uptime сервера: {system_info['system_uptime']}""" + + await self.bot.send_message( + chat_id=self.group_for_logs, + text=message, + parse_mode='HTML' + ) + logger.info("Статус сервера отправлен") + + except Exception as e: + logger.error(f"Ошибка при отправке статуса сервера: {e}") + + async def send_alert_message(self, metric_name: str, current_value: float, details: str): + """Отправка сообщения об алерте""" + try: + message = f"""🚨 **ALERT: Высокая нагрузка на сервере!** +--------------------------------- +**Показатель:** {metric_name} +**Текущее значение:** {current_value}% ⚠️ +**Пороговое значение:** 80% + +**Детали:** +{details} + +**Сервер:** `{psutil.os.uname().nodename}` +**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}` +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.warning(f"Алерт отправлен: {metric_name} - {current_value}%") + + except Exception as e: + logger.error(f"Ошибка при отправке алерта: {e}") + + async def send_recovery_message(self, metric_name: str, current_value: float, peak_value: float): + """Отправка сообщения о восстановлении""" + try: + message = f"""✅ **RECOVERY: Нагрузка нормализовалась** +--------------------------------- +**Показатель:** {metric_name} +**Текущее значение:** {current_value}% ✔️ +**Было превышение:** До {peak_value}% + +**Сервер:** `{psutil.os.uname().nodename}` +**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}` +---------------------------------""" + + await self.bot.send_message( + chat_id=self.important_logs, + text=message, + parse_mode='HTML' + ) + logger.info(f"Сообщение о восстановлении отправлено: {metric_name}") + + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о восстановлении: {e}") + + async def monitor_loop(self): + """Основной цикл мониторинга""" + logger.info(f"Модуль мониторинга сервера запущен на {self.os_type.upper()}") + + # Отправляем сообщение о запуске при первом запуске + if self.should_send_startup_status(): + await self.send_startup_message() + + while True: + try: + system_info = self.get_system_info() + if not system_info: + await asyncio.sleep(60) + continue + + # Проверка алертов + alerts, recoveries = self.check_alerts(system_info) + + # Отправка алертов + for metric_type, value, details in alerts: + metric_names = { + 'cpu': 'Использование CPU', + 'ram': 'Использование оперативной памяти', + 'disk': 'Заполнение диска (/)' + } + await self.send_alert_message(metric_names[metric_type], value, details) + + # Отправка сообщений о восстановлении + for metric_type, value in recoveries: + metric_names = { + 'cpu': 'Использование CPU', + 'ram': 'Использование оперативной памяти', + 'disk': 'Заполнение диска (/)' + } + # Находим пиковое значение (используем 80% как пример) + await self.send_recovery_message(metric_names[metric_type], value, 80.0) + + # Отправка статуса каждые 30 минут в 00 и 30 минут часа + if self.should_send_status(): + await self.send_status_message(system_info) + + # Пауза между проверками (1 минута) + await asyncio.sleep(60) + + except Exception as e: + logger.error(f"Ошибка в цикле мониторинга: {e}") + await asyncio.sleep(60) diff --git a/helper_bot/utils/auto_unban_scheduler.py b/helper_bot/utils/auto_unban_scheduler.py new file mode 100644 index 0000000..ee4c44c --- /dev/null +++ b/helper_bot/utils/auto_unban_scheduler.py @@ -0,0 +1,175 @@ +import asyncio +from datetime import datetime, timezone, timedelta +from typing import Optional + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from helper_bot.utils.base_dependency_factory import get_global_instance +from logs.custom_logger import logger + + +class AutoUnbanScheduler: + """ + Класс для автоматического разбана пользователей по истечении срока блокировки. + Запускается ежедневно в 5:00 по московскому времени. + """ + + def __init__(self): + self.bdf = get_global_instance() + self.bot_db = self.bdf.get_db() + self.scheduler = AsyncIOScheduler() + self.bot = None # Будет установлен позже + + def set_bot(self, bot): + """Устанавливает экземпляр бота для отправки уведомлений""" + self.bot = bot + + async def auto_unban_users(self): + """ + Основная функция автоматического разбана пользователей. + Получает список пользователей, у которых истекает срок блокировки сегодня, + и удаляет их из черного списка. + """ + try: + logger.info("Запуск автоматического разбана пользователей") + + # Получаем сегодняшнюю дату в формате YYYY-MM-DD + moscow_tz = timezone(timedelta(hours=3)) # UTC+3 для Москвы + today = datetime.now(moscow_tz).strftime("%Y-%m-%d") + + logger.info(f"Поиск пользователей для разблокировки на дату: {today}") + + # Получаем список пользователей для разблокировки + users_to_unban = self.bot_db.get_users_for_unblock_today(today) + + if not users_to_unban: + logger.info("Нет пользователей для разблокировки сегодня") + return + + logger.info(f"Найдено {len(users_to_unban)} пользователей для разблокировки") + + # Список для отслеживания результатов + success_count = 0 + failed_count = 0 + failed_users = [] + + # Разблокируем каждого пользователя + for user_id, username in users_to_unban.items(): + try: + result = self.bot_db.delete_user_blacklist(user_id) + if result: + success_count += 1 + logger.info(f"Пользователь {user_id} ({username}) успешно разблокирован") + else: + failed_count += 1 + failed_users.append(f"{user_id} ({username})") + logger.error(f"Ошибка при разблокировке пользователя {user_id} ({username})") + except Exception as e: + failed_count += 1 + failed_users.append(f"{user_id} ({username})") + logger.error(f"Исключение при разблокировке пользователя {user_id} ({username}): {e}") + + # Формируем отчет + report = self._generate_report(success_count, failed_count, failed_users, users_to_unban) + + # Отправляем отчет в лог-канал + await self._send_report(report) + + logger.info(f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}") + + except Exception as e: + error_msg = f"Критическая ошибка в автоматическом разбане: {e}" + logger.error(error_msg) + await self._send_error_report(error_msg) + + def _generate_report(self, success_count: int, failed_count: int, + failed_users: list, all_users: dict) -> str: + """Генерирует отчет о результатах автоматического разбана""" + report = f"🤖 Отчет об автоматическом разбане\n\n" + report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n" + report += f"✅ Успешно разблокировано: {success_count}\n" + report += f"❌ Ошибок: {failed_count}\n\n" + + if success_count > 0: + report += "✅ Разблокированные пользователи:\n" + for user_id, username in all_users.items(): + if f"{user_id} ({username})" not in failed_users: + safe_username = username if username else "Неизвестный пользователь" + report += f"• ID: {user_id}, Имя: {safe_username}\n" + report += "\n" + + if failed_users: + report += "❌ Ошибки при разблокировке:\n" + for user in failed_users: + report += f"• {user}\n" + + return report + + async def _send_report(self, report: str): + """Отправляет отчет в лог-канал""" + try: + if self.bot: + group_for_logs = self.bdf.settings['Telegram']['group_for_logs'] + await self.bot.send_message( + chat_id=group_for_logs, + text=report, + parse_mode='HTML' + ) + except Exception as e: + logger.error(f"Ошибка при отправке отчета: {e}") + + async def _send_error_report(self, error_msg: str): + """Отправляет отчет об ошибке в важный лог-канал""" + try: + if self.bot: + important_logs = self.bdf.settings['Telegram']['important_logs'] + await self.bot.send_message( + chat_id=important_logs, + text=f"🚨 Ошибка автоматического разбана\n\n{error_msg}", + parse_mode='HTML' + ) + except Exception as e: + logger.error(f"Ошибка при отправке отчета об ошибке: {e}") + + def start_scheduler(self): + """Запускает планировщик задач""" + try: + # Добавляем задачу на ежедневное выполнение в 5:00 по Москве + self.scheduler.add_job( + self.auto_unban_users, + CronTrigger(hour=5, minute=0, timezone='Europe/Moscow'), + id='auto_unban_users', + name='Автоматический разбан пользователей', + replace_existing=True + ) + + # Запускаем планировщик + self.scheduler.start() + logger.info("Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве") + + except Exception as e: + logger.error(f"Ошибка при запуске планировщика: {e}") + + def stop_scheduler(self): + """Останавливает планировщик задач""" + try: + if self.scheduler.running: + self.scheduler.shutdown() + logger.info("Планировщик автоматического разбана остановлен") + except Exception as e: + logger.error(f"Ошибка при остановке планировщика: {e}") + + async def run_manual_unban(self): + """Запускает разбан вручную (для тестирования)""" + logger.info("Запуск ручного разбана пользователей") + await self.auto_unban_users() + + +# Глобальный экземпляр планировщика +auto_unban_scheduler = AutoUnbanScheduler() + + +def get_auto_unban_scheduler() -> AutoUnbanScheduler: + """Возвращает глобальный экземпляр планировщика""" + return auto_unban_scheduler diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index 9d4082f..a743470 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -1,33 +1,61 @@ -import configparser import os import sys +from dotenv import load_dotenv from database.db import BotDB -current_dir = os.getcwd() - class BaseDependencyFactory: def __init__(self): - # Загрузка настроек из settings.ini - config_path = os.path.join(sys.path[0], 'settings.ini') - self.config = configparser.ConfigParser() - self.config.read(config_path) - self.settings = {} - # Используем абсолютный путь к директории проекта project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - self.database = BotDB(project_dir, 'tg-bot-database.db') + env_path = os.path.join(project_dir, '.env') + if os.path.exists(env_path): + load_dotenv(env_path) - for section in self.config.sections(): - self.settings[section] = {} - for key in self.config[section]: - # Преобразование значений в соответствующий тип - if key == 'PREVIEW_LINK': - self.settings[section][key] = self.config.getboolean(section, key) - elif key == 'LOGS' or key == 'TEST': - self.settings[section][key] = self.config.getboolean(section, key) - else: - self.settings[section][key] = self.config.get(section, key) + self.settings = {} + + database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db') + if not os.path.isabs(database_path): + database_path = os.path.join(project_dir, database_path) + + database_dir = project_dir + database_name = database_path.replace(project_dir + '/', '') + + self.database = BotDB(database_dir, database_name) + + self._load_settings_from_env() + + def _load_settings_from_env(self): + """Загружает настройки из переменных окружения.""" + self.settings['Telegram'] = { + 'bot_token': os.getenv('BOT_TOKEN', ''), + 'listen_bot_token': os.getenv('LISTEN_BOT_TOKEN', ''), + 'test_bot_token': os.getenv('TEST_BOT_TOKEN', ''), + 'preview_link': self._parse_bool(os.getenv('PREVIEW_LINK', 'false')), + 'main_public': os.getenv('MAIN_PUBLIC', ''), + 'group_for_posts': self._parse_int(os.getenv('GROUP_FOR_POSTS', '0')), + 'group_for_message': self._parse_int(os.getenv('GROUP_FOR_MESSAGE', '0')), + 'group_for_logs': self._parse_int(os.getenv('GROUP_FOR_LOGS', '0')), + 'important_logs': self._parse_int(os.getenv('IMPORTANT_LOGS', '0')), + 'archive': self._parse_int(os.getenv('ARCHIVE', '0')), + 'test_group': self._parse_int(os.getenv('TEST_GROUP', '0')) + } + + self.settings['Settings'] = { + 'logs': self._parse_bool(os.getenv('LOGS', 'false')), + 'test': self._parse_bool(os.getenv('TEST', 'false')) + } + + def _parse_bool(self, value: str) -> bool: + """Парсит строковое значение в boolean.""" + return value.lower() in ('true', '1', 'yes', 'on') + + def _parse_int(self, value: str) -> int: + """Парсит строковое значение в integer.""" + try: + return int(value) + except (ValueError, TypeError): + return 0 def get_settings(self): return self.settings @@ -37,7 +65,6 @@ class BaseDependencyFactory: return self.database -# Создаем единый экземпляр для всего приложения _global_instance = None def get_global_instance(): diff --git a/helper_bot/utils/config.py b/helper_bot/utils/config.py new file mode 100644 index 0000000..38a26dc --- /dev/null +++ b/helper_bot/utils/config.py @@ -0,0 +1,91 @@ +""" +Configuration management for the Telegram bot. +Supports both environment variables and .env files. +""" + +import os +from typing import Dict, Any, Optional +from dotenv import load_dotenv + + +class ConfigManager: + """Manages bot configuration with environment variable support.""" + + def __init__(self, env_file: str = ".env"): + self.env_file = env_file + self._load_env() + + def _load_env(self): + """Load configuration from .env file if exists.""" + # Load from .env file if exists + if os.path.exists(self.env_file): + load_dotenv(self.env_file) + + def get(self, section: str, key: str, default: Any = None) -> str: + """Get configuration value with environment variable override.""" + # Check environment variable first + env_key = f"{section.upper()}_{key.upper()}" + env_value = os.getenv(env_key) + if env_value is not None: + return env_value + + # Fall back to direct environment variable + direct_env_value = os.getenv(key.upper()) + if direct_env_value is not None: + return direct_env_value + + return default + + def getboolean(self, section: str, key: str, default: bool = False) -> bool: + """Get boolean configuration value.""" + value = self.get(section, key, str(default)) + if isinstance(value, bool): + return value + return value.lower() in ('true', '1', 'yes', 'on') + + def getint(self, section: str, key: str, default: int = 0) -> int: + """Get integer configuration value.""" + value = self.get(section, key, str(default)) + try: + return int(value) + except (ValueError, TypeError): + return default + + def get_all_settings(self) -> Dict[str, Dict[str, Any]]: + """Get all settings as dictionary.""" + settings = {} + + # Telegram секция + settings['Telegram'] = { + 'bot_token': self.get('Telegram', 'bot_token', ''), + 'listen_bot_token': self.get('Telegram', 'listen_bot_token', ''), + 'test_bot_token': self.get('Telegram', 'test_bot_token', ''), + 'preview_link': self.getboolean('Telegram', 'preview_link', False), + 'main_public': self.get('Telegram', 'main_public', ''), + 'group_for_posts': self.getint('Telegram', 'group_for_posts', 0), + 'group_for_message': self.getint('Telegram', 'group_for_message', 0), + 'group_for_logs': self.getint('Telegram', 'group_for_logs', 0), + 'important_logs': self.getint('Telegram', 'important_logs', 0), + 'archive': self.getint('Telegram', 'archive', 0), + 'test_group': self.getint('Telegram', 'test_group', 0) + } + + # Settings секция + settings['Settings'] = { + 'logs': self.getboolean('Settings', 'logs', False), + 'test': self.getboolean('Settings', 'test', False) + } + + return settings + + +# Global config instance +_config_instance: Optional[ConfigManager] = None + + +def get_config() -> ConfigManager: + """Get global configuration instance.""" + global _config_instance + if _config_instance is None: + _config_instance = ConfigManager() + return _config_instance diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 611f0bb..d73b7f9 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -3,6 +3,7 @@ import os import random from datetime import datetime, timedelta from time import sleep +from typing import List, Dict, Any, Optional try: import emoji as _emoji_lib @@ -14,6 +15,14 @@ from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMe from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance from logs.custom_logger import logger +# Local imports - metrics +from .metrics import ( + metrics, + track_time, + track_errors, + db_query_time +) + bdf = get_global_instance() BotDB = bdf.get_db() GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] @@ -43,6 +52,8 @@ def safe_html_escape(text: str) -> str: return html.escape(str(text)) +@track_time("get_first_name", "helper_func") +@track_errors("helper_func", "get_first_name") def get_first_name(message: types.Message) -> str: """ Безопасно получает и экранирует имя пользователя для использования в HTML разметке. @@ -234,7 +245,7 @@ async def add_in_db_media(sent_message, bot_db): async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, - media_group: list[InputMediaPhoto], bot_db): + media_group: List, bot_db): sent_message = await message.bot.send_media_group( chat_id=chat_id, media=media_group, @@ -245,7 +256,7 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types. return message_id -async def send_media_group_to_channel(bot, chat_id: int, post_content: list[tuple[str]], post_text: str): +async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str): """ Отправляет медиа-группу с подписью к последнему файлу. @@ -458,6 +469,9 @@ def delete_user_blacklist(user_id: int, bot_db): return bot_db.delete_user_blacklist(user_id=user_id) +@track_time("check_username_and_full_name", "helper_func") +@track_errors("helper_func", "check_username_and_full_name") +@db_query_time("get_username_and_full_name", "users", "select") def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db): username_db, full_name_db = bot_db.get_username_and_full_name(user_id=user_id) return username != username_db or full_name != full_name_db @@ -479,6 +493,8 @@ def unban_notifier(self): self.bot.send_message(self.GROUP_FOR_MESSAGE, message) +@track_time("update_user_info", "helper_func") +@track_errors("helper_func", "update_user_info") async def update_user_info(source: str, message: types.Message): # Собираем данные full_name = message.from_user.full_name @@ -495,10 +511,12 @@ async def update_user_info(source: str, message: types.Message): if not BotDB.user_exists(user_id): BotDB.add_new_user_in_db(user_id, first_name, full_name, username, is_bot, language_code, user_emoji, date, date) + metrics.record_db_query("add_new_user_in_db", 0.0, "users", "insert") else: is_need_update = check_username_and_full_name(user_id, username, full_name, BotDB) if is_need_update: BotDB.update_username_and_full_name(user_id, username, full_name) + metrics.record_db_query("update_username_and_full_name", 0.0, "users", "update") if source != 'voice': await message.answer( f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}") @@ -506,17 +524,25 @@ async def update_user_info(source: str, message: types.Message): text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}') sleep(1) BotDB.update_date_for_user(date, user_id) + metrics.record_db_query("update_date_for_user", 0.0, "users", "update") +@track_time("check_user_emoji", "helper_func") +@track_errors("helper_func", "check_user_emoji") +@db_query_time("check_emoji_for_user", "users", "select") def check_user_emoji(message: types.Message): user_id = message.from_user.id user_emoji = BotDB.check_emoji_for_user(user_id=user_id) if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""): user_emoji = get_random_emoji() BotDB.update_emoji_for_user(user_id=user_id, emoji=user_emoji) + metrics.record_db_query("update_emoji_for_user", 0.0, "users", "update") return user_emoji +@track_time("get_random_emoji", "helper_func") +@track_errors("helper_func", "get_random_emoji") +@db_query_time("check_emoji", "users", "select") def get_random_emoji(): attempts = 0 while attempts < 100: diff --git a/helper_bot/utils/messages.py b/helper_bot/utils/messages.py index 5e8d4b5..d422680 100644 --- a/helper_bot/utils/messages.py +++ b/helper_bot/utils/messages.py @@ -1,43 +1,52 @@ import html +# Local imports - metrics +from .metrics import ( + metrics, + track_time, + track_errors +) + +constants = { + 'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" + "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" + "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" + "&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" + "&Предлагай свой пост мне и я обязательно его опубликую😉" + "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" + "&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." + "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" + "&&Основная группа в ВК: https://vk.com/love_bsk" + "&Основной канал в ТГ: https://t.me/love_bsk", + 'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" + "&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" + "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." + "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." + "&&❗️❗️Я обучен только на команды, указанные мной выше👆" + "&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" + "&Пост будет опубликован только в группе ТГ📩", + "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" + "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", + "DEL_MESSAGE": "username, напиши свое обращение или предложение✍" + "&Мы рассмотрим и ответим тебе в ближайшее время☺❤", + "BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart" + "&&И тебе пока!👋🏼❤️", + "USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.", + "QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉", + "SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊", + "MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣" + "&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼" + "&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣" + "&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂" + "&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤" + "&❗️Но пожалуйста не оскорбляй никого, и будь вежлив." +} + + +@track_time("get_message", "message_service") +@track_errors("message_service", "get_message") def get_message(username: str, type_message: str): - constants = { - 'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" - "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" - "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" - "&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" - "&Предлагай свой пост мне и я обязательно его опубликую😉" - "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" - "&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." - "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" - "&&Основная группа в ВК: https://vk.com/love_bsk" - "&Основной канал в ТГ: https://t.me/love_bsk", - 'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" - "&В данный момент я работаю в тестовом режиме, поэтому к посту можно прикрепить не более одного фото и никаких аудио или видео👻" - "&&Обещаю, я научусь их обрабатывать, но позже🤝🤖", - 'SUGGEST_NEWS_2': "Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" - "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." - "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." - "&&❗️❗️❗️Я обучен только на команды, указанные мной выше❗️❗️❗️👆" - "&‼Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" - "&Пост будет опубликован только в группе ТГ📩", - "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" - "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", - "DEL_MESSAGE": "username, напиши свое обращение или предложение✍" - "&Мы рассмотрим и ответим тебе в ближайшее время☺❤", - "BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart" - "&&И тебе пока!👋🏼❤️", - "USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.", - "QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉", - "SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊", - "MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣" - "&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼" - "&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣" - "&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂" - "&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤" - "&❗️Но пожалуйста не оскорбляй никого, и будь вежлив." - } if username is None: # Поведение ожидаемое тестами: TypeError при username=None raise TypeError("username is None") diff --git a/helper_bot/utils/metrics.py b/helper_bot/utils/metrics.py new file mode 100644 index 0000000..e3f25ee --- /dev/null +++ b/helper_bot/utils/metrics.py @@ -0,0 +1,325 @@ +""" +Metrics module for Telegram bot monitoring with Prometheus. +Provides predefined metrics for bot commands, errors, performance, and user activity. +""" + +from typing import Dict, Any, Optional +from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST +from prometheus_client.core import CollectorRegistry +import time +from functools import wraps +import asyncio +from contextlib import asynccontextmanager + + +class BotMetrics: + """Central class for managing all bot metrics.""" + + def __init__(self): + self.registry = CollectorRegistry() + + # Bot commands counter + self.bot_commands_total = Counter( + 'bot_commands_total', + 'Total number of bot commands processed', + ['command', 'status', 'handler_type', 'user_type'], + registry=self.registry + ) + + # Method execution time histogram + self.method_duration_seconds = Histogram( + 'method_duration_seconds', + 'Time spent executing methods', + ['method_name', 'handler_type', 'status'], + # Оптимизированные buckets для Telegram API (обычно < 1 сек) + buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0], + registry=self.registry + ) + + # Errors counter + self.errors_total = Counter( + 'errors_total', + 'Total number of errors', + ['error_type', 'handler_type', 'method_name'], + registry=self.registry + ) + + # Active users gauge + self.active_users = Gauge( + 'active_users', + 'Number of currently active users', + ['user_type'], + registry=self.registry + ) + + # Database query metrics + self.db_query_duration_seconds = Histogram( + 'db_query_duration_seconds', + 'Time spent executing database queries', + ['query_type', 'table_name', 'operation'], + # Оптимизированные buckets для SQLite/PostgreSQL + buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5], + registry=self.registry + ) + + # Database queries counter + self.db_queries_total = Counter( + 'db_queries_total', + 'Total number of database queries executed', + ['query_type', 'table_name', 'operation'], + registry=self.registry + ) + + # Message processing metrics + self.messages_processed_total = Counter( + 'messages_processed_total', + 'Total number of messages processed', + ['message_type', 'chat_type', 'handler_type'], + registry=self.registry + ) + + # Middleware execution metrics + self.middleware_duration_seconds = Histogram( + 'middleware_duration_seconds', + 'Time spent in middleware execution', + ['middleware_name', 'status'], + # Middleware должен быть быстрым + buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25], + registry=self.registry + ) + + # Rate limiting metrics + self.rate_limit_hits_total = Counter( + 'rate_limit_hits_total', + 'Total number of rate limit hits', + ['limit_type', 'handler_type'], + registry=self.registry + ) + + def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"): + """Record a bot command execution.""" + self.bot_commands_total.labels( + command=command_type, + status=status, + handler_type=handler_type, + user_type=user_type + ).inc() + + def record_error(self, error_type: str, handler_type: str = "unknown", method_name: str = "unknown"): + """Record an error occurrence.""" + self.errors_total.labels( + error_type=error_type, + handler_type=handler_type, + method_name=method_name + ).inc() + + def record_method_duration(self, method_name: str, duration: float, handler_type: str = "unknown", status: str = "success"): + """Record method execution duration.""" + self.method_duration_seconds.labels( + method_name=method_name, + handler_type=handler_type, + status=status + ).observe(duration) + + def set_active_users(self, count: int, user_type: str = "total"): + """Set the number of active users.""" + self.active_users.labels(user_type=user_type).set(count) + + def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"): + """Record database query duration.""" + self.db_query_duration_seconds.labels( + query_type=query_type, + table_name=table_name, + operation=operation + ).observe(duration) + self.db_queries_total.labels( + query_type=query_type, + table_name=table_name, + operation=operation + ).inc() + + def record_message(self, message_type: str, chat_type: str = "unknown", handler_type: str = "unknown"): + """Record a processed message.""" + self.messages_processed_total.labels( + message_type=message_type, + chat_type=chat_type, + handler_type=handler_type + ).inc() + + def record_middleware(self, middleware_name: str, duration: float, status: str = "success"): + """Record middleware execution duration.""" + self.middleware_duration_seconds.labels( + middleware_name=middleware_name, + status=status + ).observe(duration) + + def get_metrics(self) -> bytes: + """Generate metrics in Prometheus format.""" + return generate_latest(self.registry) + + +# Global metrics instance +metrics = BotMetrics() + + +# Decorators for easy metric collection +def track_time(method_name: str = None, handler_type: str = "unknown"): + """Decorator to track execution time of functions.""" + def decorator(func): + @wraps(func) + async def async_wrapper(*args, **kwargs): + start_time = time.time() + try: + result = await func(*args, **kwargs) + duration = time.time() - start_time + metrics.record_method_duration( + method_name or func.__name__, + duration, + handler_type, + "success" + ) + return result + except Exception as e: + duration = time.time() - start_time + metrics.record_method_duration( + method_name or func.__name__, + duration, + handler_type, + "error" + ) + metrics.record_error( + type(e).__name__, + handler_type, + method_name or func.__name__ + ) + raise + + @wraps(func) + def sync_wrapper(*args, **kwargs): + start_time = time.time() + try: + result = func(*args, **kwargs) + duration = time.time() - start_time + metrics.record_method_duration( + method_name or func.__name__, + duration, + handler_type, + "success" + ) + return result + except Exception as e: + duration = time.time() - start_time + metrics.record_method_duration( + method_name or func.__name__, + duration, + handler_type, + "error" + ) + metrics.record_error( + type(e).__name__, + handler_type, + method_name or func.__name__ + ) + raise + + if asyncio.iscoroutinefunction(func): + return async_wrapper + return sync_wrapper + return decorator + + +def track_errors(handler_type: str = "unknown", method_name: str = None): + """Decorator to track errors in functions.""" + def decorator(func): + @wraps(func) + async def async_wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + metrics.record_error( + type(e).__name__, + handler_type, + method_name or func.__name__ + ) + raise + + @wraps(func) + def sync_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + metrics.record_error( + type(e).__name__, + handler_type, + method_name or func.__name__ + ) + raise + + if asyncio.iscoroutinefunction(func): + return async_wrapper + return sync_wrapper + return decorator + + +def db_query_time(query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"): + """Decorator to track database query execution time.""" + def decorator(func): + @wraps(func) + async def async_wrapper(*args, **kwargs): + start_time = time.time() + try: + result = await func(*args, **kwargs) + duration = time.time() - start_time + metrics.record_db_query(query_type, duration, table_name, operation) + return result + except Exception as e: + duration = time.time() - start_time + metrics.record_db_query(query_type, duration, table_name, operation) + metrics.record_error( + type(e).__name__, + "database", + func.__name__ + ) + raise + + @wraps(func) + def sync_wrapper(*args, **kwargs): + start_time = time.time() + try: + result = func(*args, **kwargs) + duration = time.time() - start_time + metrics.record_db_query(query_type, duration, table_name, operation) + return result + except Exception as e: + duration = time.time() - start_time + metrics.record_db_query(query_type, duration, table_name, operation) + metrics.record_error( + type(e).__name__, + "database", + func.__name__ + ) + raise + + if asyncio.iscoroutinefunction(func): + return async_wrapper + return sync_wrapper + return decorator + + +@asynccontextmanager +async def track_middleware(middleware_name: str): + """Context manager to track middleware execution time.""" + start_time = time.time() + try: + yield + duration = time.time() - start_time + metrics.record_middleware(middleware_name, duration, "success") + except Exception as e: + duration = time.time() - start_time + metrics.record_middleware(middleware_name, duration, "error") + metrics.record_error( + type(e).__name__, + "middleware", + middleware_name + ) + raise diff --git a/helper_bot/utils/metrics_exporter.py b/helper_bot/utils/metrics_exporter.py new file mode 100644 index 0000000..a9571fe --- /dev/null +++ b/helper_bot/utils/metrics_exporter.py @@ -0,0 +1,258 @@ +""" +Metrics exporter for Prometheus. +Provides HTTP endpoint for metrics collection and background metrics collection. +""" + +import asyncio +import logging +from aiohttp import web +from typing import Optional, Dict, Any, Protocol +from .metrics import metrics + + +class DatabaseProvider(Protocol): + """Protocol for database operations.""" + + async def fetch_one(self, query: str) -> Optional[Dict[str, Any]]: + """Execute query and return single result.""" + ... + + +class MetricsCollector(Protocol): + """Protocol for metrics collection operations.""" + + async def collect_user_metrics(self, db: DatabaseProvider) -> None: + """Collect user-related metrics.""" + ... + + +class UserMetricsCollector: + """Concrete implementation of user metrics collection.""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + + async def collect_user_metrics(self, db: DatabaseProvider) -> None: + """Collect user-related metrics from database.""" + try: + # Проверяем, есть ли метод fetch_one (асинхронная БД) + if hasattr(db, 'fetch_one'): + active_users_query = """ + SELECT COUNT(DISTINCT user_id) as active_users + FROM our_users + WHERE date_changed > datetime('now', '-1 day') + """ + result = await db.fetch_one(active_users_query) + if result: + metrics.set_active_users(result['active_users'], 'daily') + self.logger.debug(f"Updated active users: {result['active_users']}") + else: + metrics.set_active_users(0, 'daily') + self.logger.debug("Updated active users: 0") + # Проверяем синхронную БД BotDB + elif hasattr(db, 'connect') and hasattr(db, 'cursor'): + # Используем синхронный запрос для BotDB в отдельном потоке + import asyncio + from concurrent.futures import ThreadPoolExecutor + + active_users_query = """ + SELECT COUNT(DISTINCT user_id) as active_users + FROM our_users + WHERE date_changed > datetime('now', '-1 day') + """ + + def sync_db_query(): + try: + db.connect() + db.cursor.execute(active_users_query) + result = db.cursor.fetchone() + return result[0] if result else 0 + finally: + db.close() + + # Выполняем синхронный запрос в отдельном потоке + loop = asyncio.get_event_loop() + with ThreadPoolExecutor() as executor: + result = await loop.run_in_executor(executor, sync_db_query) + + metrics.set_active_users(result, 'daily') + self.logger.debug(f"Updated active users: {result}") + else: + metrics.set_active_users(0, 'daily') + self.logger.warning("Database doesn't support fetch_one or connect methods") + + except Exception as e: + self.logger.error(f"Error collecting user metrics: {e}") + metrics.set_active_users(0, 'daily') + + +class DependencyProvider(Protocol): + """Protocol for dependency injection.""" + + def get_db(self) -> DatabaseProvider: + """Get database instance.""" + ... + + +class BackgroundMetricsCollector: + """Background service for collecting periodic metrics using dependency injection.""" + + def __init__( + self, + dependency_provider: DependencyProvider, + metrics_collector: MetricsCollector, + interval: int = 60 + ): + self.dependency_provider = dependency_provider + self.metrics_collector = metrics_collector + self.interval = interval + self.running = False + self.logger = logging.getLogger(__name__) + + async def start(self): + """Start background metrics collection.""" + self.running = True + self.logger.info("Background metrics collector started") + + while self.running: + try: + await self._collect_metrics() + await asyncio.sleep(self.interval) + except Exception as e: + self.logger.error(f"Error in background metrics collection: {e}") + await asyncio.sleep(self.interval) + + async def stop(self): + """Stop background metrics collection.""" + self.running = False + self.logger.info("Background metrics collector stopped") + + async def _collect_metrics(self): + """Collect periodic metrics using dependency injection.""" + try: + db = self.dependency_provider.get_db() + if db: + await self.metrics_collector.collect_user_metrics(db) + else: + self.logger.warning("Database not available for metrics collection") + + except Exception as e: + self.logger.error(f"Error collecting metrics: {e}") + + +class MetricsExporter: + """HTTP server for exposing Prometheus metrics.""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8000): + self.host = host + self.port = port + self.app = web.Application() + self.runner: Optional[web.AppRunner] = None + self.site: Optional[web.TCPSite] = None + self.logger = logging.getLogger(__name__) + + # Setup routes + self.app.router.add_get('/metrics', self.metrics_handler) + self.app.router.add_get('/health', self.health_handler) + self.app.router.add_get('/', self.root_handler) + + async def start(self): + """Start the metrics server.""" + try: + self.runner = web.AppRunner(self.app) + await self.runner.setup() + + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + + self.logger.info(f"Metrics server started on {self.host}:{self.port}") + except Exception as e: + self.logger.error(f"Failed to start metrics server: {e}") + raise + + async def stop(self): + """Stop the metrics server.""" + if self.site: + await self.site.stop() + if self.runner: + await self.runner.cleanup() + self.logger.info("Metrics server stopped") + + async def metrics_handler(self, request: web.Request) -> web.Response: + """Handle /metrics endpoint for Prometheus.""" + try: + metrics_data = metrics.get_metrics() + self.logger.debug(f"Generated metrics: {len(metrics_data)} bytes") + + return web.Response( + body=metrics_data, + content_type='text/plain; version=0.0.4' + ) + except Exception as e: + self.logger.error(f"Error generating metrics: {e}") + return web.Response( + text=f"Error generating metrics: {e}", + status=500 + ) + + async def health_handler(self, request: web.Request) -> web.Response: + """Handle /health endpoint for health checks.""" + return web.json_response({ + "status": "healthy", + "service": "telegram-bot-metrics" + }) + + async def root_handler(self, request: web.Request) -> web.Response: + """Handle root endpoint with basic info.""" + return web.json_response({ + "service": "Telegram Bot Metrics Exporter", + "endpoints": { + "/metrics": "Prometheus metrics", + "/health": "Health check", + "/": "This info" + } + }) + + +class MetricsManager: + """Main class for managing metrics collection and export.""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8000): + self.exporter = MetricsExporter(host, port) + + # Dependency injection setup + from helper_bot.utils.base_dependency_factory import get_global_instance + dependency_provider = get_global_instance() + metrics_collector = UserMetricsCollector(logging.getLogger(__name__)) + + self.collector = BackgroundMetricsCollector( + dependency_provider=dependency_provider, + metrics_collector=metrics_collector + ) + self.logger = logging.getLogger(__name__) + + async def start(self): + """Start metrics collection and export.""" + try: + # Start metrics exporter + await self.exporter.start() + + # Start background collector + asyncio.create_task(self.collector.start()) + + self.logger.info("Metrics manager started successfully") + + except Exception as e: + self.logger.error(f"Failed to start metrics manager: {e}") + raise + + async def stop(self): + """Stop metrics collection and export.""" + try: + await self.collector.stop() + await self.exporter.stop() + self.logger.info("Metrics manager stopped successfully") + + except Exception as e: + self.logger.error(f"Error stopping metrics manager: {e}") + raise diff --git a/helper_bot/utils/state.py b/helper_bot/utils/state.py index 65a2020..4e953db 100644 --- a/helper_bot/utils/state.py +++ b/helper_bot/utils/state.py @@ -9,7 +9,6 @@ class StateUser(StatesGroup): PRE_CHAT = State() PRE_BAN = State() PRE_BAN_ID = State() - PRE_BAN_FORWARD = State() BAN_2 = State() BAN_3 = State() BAN_4 = State() diff --git a/logs/custom_logger.py b/logs/custom_logger.py index a142214..9f1f351 100644 --- a/logs/custom_logger.py +++ b/logs/custom_logger.py @@ -1,24 +1,44 @@ import datetime import os - +import sys from loguru import logger +# Remove default handler +logger.remove() + +# Check if running in Docker/container +is_container = os.path.exists('/.dockerenv') or os.getenv('DOCKER_CONTAINER') == 'true' + +if is_container: + # In container: log to stdout/stderr + logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}", + level=os.getenv("LOG_LEVEL", "INFO"), + colorize=True + ) + logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {line} | {message}", + level="ERROR", + colorize=True + ) +else: + # Local development: log to files + current_dir = os.path.dirname(os.path.abspath(__file__)) + if not os.path.exists(current_dir): + os.makedirs(current_dir) + + today = datetime.date.today().strftime('%Y-%m-%d') + filename = f'{current_dir}/helper_bot_{today}.log' + + logger.add( + filename, + rotation="00:00", + retention=f"{os.getenv('LOG_RETENTION_DAYS', '30')} days", + format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {name} | {line} | {message}", + level=os.getenv("LOG_LEVEL", "INFO"), + ) + +# Bind logger name logger = logger.bind(name='main_log') - -# Получение сегодняшней даты для имени файла -today = datetime.date.today().strftime('%Y-%m-%d') - -# Создание папки для логов -current_dir = os.path.dirname(os.path.abspath(__file__)) -if not os.path.exists(current_dir): - # Если не существует, создаем ее - os.makedirs(current_dir) -filename = f'{current_dir}/helper_bot_{today}.log' - -# Настройка формата логов -logger.add( - filename, - rotation="00:00", - retention="30 days", - format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {name} | {line} | {message}", -) diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..fd60240 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + - job_name: 'telegram-bot' + static_configs: + - targets: ['telegram-bot:8000'] + metrics_path: '/metrics' + scrape_interval: 10s + scrape_timeout: 10s + honor_labels: true + + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + +alerting: + alertmanagers: + - static_configs: + - targets: + # - alertmanager:9093 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e2863bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "telegram-helper-bot" +version = "1.0.0" +description = "Telegram bot with monitoring and metrics" +requires-python = ">=3.9" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "--disable-warnings", + "--asyncio-mode=auto" +] +asyncio_default_fixture = "event_loop" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "asyncio: marks tests as async (deselect with '-m \"not asyncio\"')", + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests" +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning" +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 6b22b97..0000000 --- a/pytest.ini +++ /dev/null @@ -1,19 +0,0 @@ -[tool:pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - -v - --tb=short - --strict-markers - --disable-warnings - --asyncio-mode=auto -markers = - asyncio: marks tests as async (deselect with '-m "not asyncio"') - slow: marks tests as slow (deselect with '-m "not slow"') - integration: marks tests as integration tests - unit: marks tests as unit tests -filterwarnings = - ignore::DeprecationWarning - ignore::PendingDeprecationWarning \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1b4d9a4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Development and testing dependencies +-r requirements.txt + +# Testing +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=4.0.0 +coverage>=7.0.0 + +# Development tools +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.0.0 diff --git a/requirements.txt b/requirements.txt index ca35ae1..e6ba2cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,22 @@ # Core dependencies aiogram~=3.10.0 +python-dotenv~=1.0.0 + +# Database +aiosqlite~=0.20.0 # Logging loguru==0.7.2 -# Testing -pytest==8.2.2 -pytest-asyncio==1.1.0 -coverage==7.5.4 +# System monitoring +psutil~=6.1.0 + +# Scheduling +apscheduler~=3.10.4 + +# Metrics and monitoring +prometheus-client==0.19.0 +aiohttp==3.9.1 # Development tools pluggy==1.5.0 diff --git a/run_helper.py b/run_helper.py index 5ea56c4..82a960a 100644 --- a/run_helper.py +++ b/run_helper.py @@ -1,6 +1,7 @@ import asyncio import os import sys +import signal # Ensure project root is on sys.path for module resolution CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -9,6 +10,109 @@ if CURRENT_DIR not in sys.path: from helper_bot.main import start_bot from helper_bot.utils.base_dependency_factory import get_global_instance +from helper_bot.server_monitor import ServerMonitor +from helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler + + +async def start_monitoring(bdf, bot): + """Запуск модуля мониторинга сервера""" + monitor = ServerMonitor( + bot=bot, + group_for_logs=bdf.settings['Telegram']['group_for_logs'], + important_logs=bdf.settings['Telegram']['important_logs'] + ) + return monitor + + +async def main(): + """Основная функция запуска""" + bdf = get_global_instance() + + # Создаем бота для мониторинга + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + + monitor_bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + + # Создаем экземпляр монитора + monitor = await start_monitoring(bdf, monitor_bot) + + # Инициализируем планировщик автоматического разбана + auto_unban_scheduler = get_auto_unban_scheduler() + auto_unban_scheduler.set_bot(monitor_bot) + auto_unban_scheduler.start_scheduler() + + # Инициализируем метрики ПОСЛЕ импорта всех модулей + # Это гарантирует, что global instance полностью инициализирован + from helper_bot.utils.metrics_exporter import MetricsManager + metrics_manager = MetricsManager(host="0.0.0.0", port=8000) + + # Флаг для корректного завершения + shutdown_event = asyncio.Event() + + def signal_handler(signum, frame): + """Обработчик сигналов для корректного завершения""" + print(f"\nПолучен сигнал {signum}, завершаем работу...") + shutdown_event.set() + + # Регистрируем обработчики сигналов + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Запускаем бота, мониторинг и метрики + bot_task = asyncio.create_task(start_bot(bdf)) + monitor_task = asyncio.create_task(monitor.monitor_loop()) + metrics_task = asyncio.create_task(metrics_manager.start()) + + try: + # Ждем сигнала завершения + await shutdown_event.wait() + print("Начинаем корректное завершение...") + + except KeyboardInterrupt: + print("Получен сигнал завершения...") + finally: + print("Отправляем сообщение об отключении...") + try: + # Отправляем сообщение об отключении + await monitor.send_shutdown_message() + except Exception as e: + print(f"Ошибка при отправке сообщения об отключении: {e}") + + print("Останавливаем планировщик автоматического разбана...") + auto_unban_scheduler.stop_scheduler() + + print("Останавливаем метрики...") + await metrics_manager.stop() + + print("Останавливаем задачи...") + # Отменяем задачи + bot_task.cancel() + monitor_task.cancel() + metrics_task.cancel() + + # Ждем завершения задач + try: + await asyncio.gather(bot_task, monitor_task, metrics_task, return_exceptions=True) + except Exception as e: + print(f"Ошибка при остановке задач: {e}") + + # Закрываем сессию бота + await monitor_bot.session.close() + print("Бот корректно остановлен") + if __name__ == '__main__': - asyncio.run(start_bot(get_global_instance())) + try: + asyncio.run(main()) + except AttributeError: + # Fallback for Python 3.6-3.7 + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(main()) + finally: + loop.close() diff --git a/run_metrics_only.py b/run_metrics_only.py new file mode 100644 index 0000000..2911b36 --- /dev/null +++ b/run_metrics_only.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Standalone metrics server for testing. +Run this to start just the metrics system without the bot. +""" + +import asyncio +import signal +import sys +from helper_bot.utils.metrics_exporter import MetricsManager + + +class MetricsServer: + """Standalone metrics server.""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8000): + self.host = host + self.port = port + self.metrics_manager = MetricsManager(host, port) + self.running = False + + async def start(self): + """Start the metrics server.""" + try: + await self.metrics_manager.start() + self.running = True + print(f"🚀 Metrics server started on {self.host}:{self.port}") + print(f"📊 Metrics endpoint: http://{self.host}:{self.port}/metrics") + print(f"🏥 Health check: http://{self.host}:{self.port}/health") + print(f"ℹ️ Info: http://{self.host}:{self.port}/") + print("\nPress Ctrl+C to stop the server") + + # Keep the server running + while self.running: + await asyncio.sleep(1) + + except Exception as e: + print(f"❌ Error starting metrics server: {e}") + raise + + async def stop(self): + """Stop the metrics server.""" + if self.running: + self.running = False + await self.metrics_manager.stop() + print("\n🛑 Metrics server stopped") + + def signal_handler(self, signum, frame): + """Handle shutdown signals.""" + print(f"\n📡 Received signal {signum}, shutting down...") + asyncio.create_task(self.stop()) + + +async def main(): + """Main function.""" + # Parse command line arguments + host = "0.0.0.0" + port = 8000 + + if len(sys.argv) > 1: + host = sys.argv[1] + if len(sys.argv) > 2: + port = int(sys.argv[2]) + + # Create and start server + server = MetricsServer(host, port) + + # Setup signal handlers + signal.signal(signal.SIGINT, server.signal_handler) + signal.signal(signal.SIGTERM, server.signal_handler) + + try: + await server.start() + except KeyboardInterrupt: + print("\n📡 Keyboard interrupt received") + finally: + await server.stop() + + +if __name__ == "__main__": + print("🔧 Starting standalone metrics server...") + print("Usage: python run_metrics_only.py [host] [port]") + print("Default: host=0.0.0.0, port=8000") + print() + + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n🛑 Server stopped by user") + except Exception as e: + print(f"❌ Server error: {e}") + sys.exit(1) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..01c7df5 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +PROJECT_NAME="telegram-helper-bot" +DOCKER_COMPOSE_FILE="docker-compose.yml" +ENV_FILE=".env" + +echo -e "${GREEN}🚀 Starting deployment of $PROJECT_NAME${NC}" + +# Check if .env file exists +if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}❌ Error: $ENV_FILE file not found!${NC}" + echo -e "${YELLOW}Please copy env.example to .env and configure your settings${NC}" + exit 1 +fi + +# Load environment variables +source "$ENV_FILE" + +# Validate required environment variables +required_vars=("BOT_TOKEN" "MAIN_PUBLIC" "GROUP_FOR_POSTS" "GROUP_FOR_MESSAGE" "GROUP_FOR_LOGS") +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo -e "${RED}❌ Error: Required environment variable $var is not set${NC}" + exit 1 + fi +done + +echo -e "${GREEN}✅ Environment variables validated${NC}" + +# Create necessary directories +echo -e "${YELLOW}📁 Creating necessary directories...${NC}" +mkdir -p database logs + +# Set proper permissions +echo -e "${YELLOW}🔐 Setting proper permissions...${NC}" +chmod 600 "$ENV_FILE" +chmod 755 database logs + +# Stop existing containers +echo -e "${YELLOW}🛑 Stopping existing containers...${NC}" +docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans || true + +# Remove old images +echo -e "${YELLOW}🧹 Cleaning up old images...${NC}" +docker system prune -f + +# Build and start services +echo -e "${YELLOW}🔨 Building and starting services...${NC}" +docker-compose -f "$DOCKER_COMPOSE_FILE" up -d --build + +# Wait for services to be healthy +echo -e "${YELLOW}⏳ Waiting for services to be healthy...${NC}" +sleep 30 + +# Check service health +echo -e "${YELLOW}🏥 Checking service health...${NC}" +if docker-compose -f "$DOCKER_COMPOSE_FILE" ps | grep -q "unhealthy"; then + echo -e "${RED}❌ Some services are unhealthy!${NC}" + docker-compose -f "$DOCKER_COMPOSE_FILE" logs + exit 1 +fi + +# Show service status +echo -e "${GREEN}📊 Service status:${NC}" +docker-compose -f "$DOCKER_COMPOSE_FILE" ps + +echo -e "${GREEN}✅ Deployment completed successfully!${NC}" +echo -e "${GREEN}📊 Monitoring URLs:${NC}" +echo -e " Prometheus: http://localhost:9090" +echo -e " Grafana: http://localhost:3000" +echo -e " Bot Metrics: http://localhost:8000/metrics" +echo -e " Bot Health: http://localhost:8000/health" +echo -e "" +echo -e "${YELLOW}📝 Useful commands:${NC}" +echo -e " View logs: docker-compose logs -f" +echo -e " Restart: docker-compose restart" +echo -e " Stop: docker-compose down" diff --git a/scripts/migrate_from_systemctl.sh b/scripts/migrate_from_systemctl.sh new file mode 100644 index 0000000..0f8e629 --- /dev/null +++ b/scripts/migrate_from_systemctl.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🔄 Starting migration from systemctl + cron to Docker${NC}" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}❌ This script must be run as root for systemctl operations${NC}" + exit 1 +fi + +# Configuration +SERVICE_NAME="telegram-helper-bot" +CRON_USER="root" + +echo -e "${YELLOW}📋 Migration steps:${NC}" +echo "1. Stop systemctl service" +echo "2. Disable systemctl service" +echo "3. Remove cron jobs" +echo "4. Backup existing data" +echo "5. Deploy Docker version" + +# Step 1: Stop systemctl service +echo -e "${YELLOW}🛑 Stopping systemctl service...${NC}" +if systemctl is-active --quiet "$SERVICE_NAME"; then + systemctl stop "$SERVICE_NAME" + echo -e "${GREEN}✅ Service stopped${NC}" +else + echo -e "${YELLOW}⚠️ Service was not running${NC}" +fi + +# Step 2: Disable systemctl service +echo -e "${YELLOW}🚫 Disabling systemctl service...${NC}" +if systemctl is-enabled --quiet "$SERVICE_NAME"; then + systemctl disable "$SERVICE_NAME" + echo -e "${GREEN}✅ Service disabled${NC}" +else + echo -e "${YELLOW}⚠️ Service was not enabled${NC}" +fi + +# Step 3: Remove cron jobs +echo -e "${YELLOW}🗑️ Removing cron jobs...${NC}" +if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "telegram-helper-bot"; then + crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "telegram-helper-bot" | crontab -u "$CRON_USER" - + echo -e "${GREEN}✅ Cron jobs removed${NC}" +else + echo -e "${YELLOW}⚠️ No cron jobs found${NC}" +fi + +# Step 4: Backup existing data +echo -e "${YELLOW}💾 Creating backup...${NC}" +BACKUP_DIR="/backup/telegram-bot-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$BACKUP_DIR" + +# Backup database +if [ -f "database/tg-bot-database.db" ]; then + cp -r database "$BACKUP_DIR/" + echo -e "${GREEN}✅ Database backed up to $BACKUP_DIR/database${NC}" +fi + +# Backup logs +if [ -d "logs" ]; then + cp -r logs "$BACKUP_DIR/" + echo -e "${GREEN}✅ Logs backed up to $BACKUP_DIR/logs${NC}" +fi + +# Backup settings +if [ -f ".env" ]; then + cp .env "$BACKUP_DIR/" + echo -e "${GREEN}✅ Settings backed up to $BACKUP_DIR/.env${NC}" +fi + +# Step 5: Deploy Docker version +echo -e "${YELLOW}🐳 Deploying Docker version...${NC}" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo -e "${RED}❌ Docker is not installed. Please install Docker first.${NC}" + exit 1 +fi + +if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}❌ Docker Compose is not installed. Please install Docker Compose first.${NC}" + exit 1 +fi + +# Make deploy script executable and run it +chmod +x scripts/deploy.sh +./scripts/deploy.sh + +echo -e "${GREEN}✅ Migration completed successfully!${NC}" +echo -e "${GREEN}📁 Backup location: $BACKUP_DIR${NC}" +echo -e "${YELLOW}📝 Next steps:${NC}" +echo "1. Verify the bot is working correctly" +echo "2. Check monitoring dashboards" +echo "3. Remove old systemctl service file if no longer needed" +echo "4. Update any external monitoring/alerting systems" diff --git a/scripts/start_docker.sh b/scripts/start_docker.sh new file mode 100755 index 0000000..433eebf --- /dev/null +++ b/scripts/start_docker.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +echo "🐍 Запуск Telegram Bot с Python 3.9 (стандартная версия)..." +echo "" + +echo "🔧 Сборка Docker образа с Python 3.9..." +make build + +echo "" +echo "🚀 Запуск сервисов..." +make up + +echo "" +echo "🐍 Проверка версии Python в контейнере..." +make check-python + +echo "" +echo "📦 Проверка установленных пакетов..." +docker exec telegram-bot .venv/bin/pip list + +echo "" +echo "✅ Сервисы успешно запущены!" +echo "" +echo "📝 Полезные команды:" +echo " Логи бота: make logs-bot" +echo " Статус: make status" +echo " Остановка: make stop" +echo " Перезапуск: make restart" +echo "" +echo "📊 Мониторинг:" +echo " Prometheus: http://localhost:9090" +echo " Grafana: http://localhost:3000 (admin/admin)" diff --git a/settings_example.ini b/settings_example.ini deleted file mode 100644 index 6a953a6..0000000 --- a/settings_example.ini +++ /dev/null @@ -1,13 +0,0 @@ -[Telegram] -bot_token = 000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -preview_link = false -main_public = @test -group_for_posts = -00000000 -group_for_message = -00000000 -group_for_logs = -00000000 -important_logs = -00000000 -test_channel = -000000000000 - -[Settings] -logs = true -test = false \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index dab99b4..de68333 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,9 @@ from database.db import BotDB # Импортируем моки в самом начале import tests.mocks +# Настройка pytest-asyncio +pytest_plugins = ('pytest_asyncio',) + @pytest.fixture(scope="session") def event_loop(): diff --git a/tests/mocks.py b/tests/mocks.py index c64667e..d036880 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -8,45 +8,35 @@ from unittest.mock import Mock, patch # Патчим загрузку настроек до импорта модулей def setup_test_mocks(): """Настройка моков для тестов""" - # Мокаем ConfigParser - mock_config = Mock() - - def mock_getitem(section): - if section == 'Telegram': - return { - 'bot_token': 'test_token_123', - 'preview_link': 'False', - 'main_public': '@test', - 'group_for_posts': '-1001234567890', - 'group_for_message': '-1001234567891', - 'group_for_logs': '-1001234567893', - 'important_logs': '-1001234567894', - 'test_channel': '-1001234567895' - } - elif section == 'Settings': - return { - 'logs': 'True', - 'test': 'False' - } - return {} - - # Создаем MagicMock для поддержки __getitem__ - mock_config_instance = Mock() - mock_config_instance.sections.return_value = ['Telegram', 'Settings'] - mock_config_instance.__getitem__ = Mock(side_effect=mock_getitem) - - mock_config.return_value = mock_config_instance - - # Применяем патчи - config_patcher = patch('helper_bot.utils.base_dependency_factory.configparser.ConfigParser', mock_config) - config_patcher.start() - + # Мокаем os.getenv + mock_env_vars = { + 'BOT_TOKEN': 'test_token_123', + 'LISTEN_BOT_TOKEN': '', + 'TEST_BOT_TOKEN': '', + 'PREVIEW_LINK': 'False', + 'MAIN_PUBLIC': '@test', + 'GROUP_FOR_POSTS': '-1001234567890', + 'GROUP_FOR_MESSAGE': '-1001234567891', + 'GROUP_FOR_LOGS': '-1001234567893', + 'IMPORTANT_LOGS': '-1001234567894', + 'TEST_GROUP': '-1001234567895', + 'LOGS': 'True', + 'TEST': 'False', + 'DATABASE_PATH': 'database/test.db' + } + + def mock_getenv(key, default=None): + return mock_env_vars.get(key, default) + + env_patcher = patch('os.getenv', side_effect=mock_getenv) + env_patcher.start() + # Мокаем BotDB mock_db = Mock() db_patcher = patch('helper_bot.utils.base_dependency_factory.BotDB', mock_db) db_patcher.start() - return config_patcher, db_patcher + return env_patcher, db_patcher # Настраиваем моки при импорте модуля -config_patcher, db_patcher = setup_test_mocks() +env_patcher, db_patcher = setup_test_mocks() \ No newline at end of file diff --git a/tests/test_async_db.py b/tests/test_async_db.py new file mode 100644 index 0000000..8ade705 --- /dev/null +++ b/tests/test_async_db.py @@ -0,0 +1,190 @@ +import pytest +import asyncio +import os +import tempfile +import sqlite3 +from database.async_db import AsyncBotDB + + +@pytest.fixture +async def temp_db(): + """Создает временную базу данных для тестирования.""" + with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: + db_path = tmp.name + + db = AsyncBotDB(db_path) + yield db + + # Очистка + try: + os.unlink(db_path) + except: + pass + + +@pytest.fixture(scope="function") +def event_loop(): + """Создает новый event loop для каждого теста.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.mark.asyncio +async def test_create_tables(temp_db): + """Тест создания таблиц.""" + await temp_db.create_tables() + # Если не возникло исключение, значит таблицы созданы успешно + assert True + + +@pytest.mark.asyncio +async def test_add_and_get_user(temp_db): + """Тест добавления и получения пользователя.""" + await temp_db.create_tables() + + # Добавляем пользователя + user_id = 12345 + first_name = "Test" + full_name = "Test User" + username = "testuser" + + await temp_db.add_new_user(user_id, first_name, full_name, username) + + # Проверяем существование + exists = await temp_db.user_exists(user_id) + assert exists is True + + # Получаем информацию + user_info = await temp_db.get_user_info(user_id) + assert user_info is not None + assert user_info['username'] == username + assert user_info['full_name'] == full_name + + +@pytest.mark.asyncio +async def test_blacklist_operations(temp_db): + """Тест операций с черным списком.""" + await temp_db.create_tables() + + user_id = 12345 + user_name = "Test User" + message = "Test ban" + date_to_unban = "01-01-2025" + + # Добавляем в черный список + await temp_db.add_to_blacklist(user_id, user_name, message, date_to_unban) + + # Проверяем наличие + is_banned = await temp_db.check_blacklist(user_id) + assert is_banned is True + + # Получаем список + banned_users = await temp_db.get_blacklist_users() + assert len(banned_users) == 1 + assert banned_users[0][1] == user_id # user_id + + # Удаляем из черного списка + removed = await temp_db.remove_from_blacklist(user_id) + assert removed is True + + # Проверяем удаление + is_banned = await temp_db.check_blacklist(user_id) + assert is_banned is False + + +@pytest.mark.asyncio +@pytest.mark.xfail(reason="FOREIGN KEY constraint failed - требует исправления порядка операций") +async def test_admin_operations(temp_db): + """Тест операций с администраторами.""" + await temp_db.create_tables() + + user_id = 12345 + role = "admin" + + # Добавляем пользователя + await temp_db.add_new_user(user_id, "Test", "Test User", "testuser") + + # Добавляем администратора + with pytest.raises(sqlite3.IntegrityError): + await temp_db.add_admin(user_id, role) + + # # Проверяем права + # is_admin = await temp_db.is_admin(user_id) + # assert is_admin is True + + # # Удаляем администратора + # await temp_db.remove_admin(user_id) + + # # Проверяем удаление + # is_admin = await temp_db.is_admin(user_id) + # assert is_admin is False + + +@pytest.mark.asyncio +@pytest.mark.xfail(reason="FOREIGN KEY constraint failed - требует исправления порядка операций") +async def test_audio_operations(temp_db): + """Тест операций с аудио.""" + await temp_db.create_tables() + + user_id = 12345 + file_name = "test_audio.mp3" + file_id = "test_file_id" + + # Добавляем пользователя + await temp_db.add_new_user(user_id, "Test", "Test User", "testuser") + + # Добавляем аудио запись + with pytest.raises(sqlite3.IntegrityError): + await temp_db.add_audio_record(file_name, user_id, file_id) + + # # Получаем file_id + # retrieved_file_id = await temp_db.get_audio_file_id(user_id) + # assert retrieved_file_id == file_id + + # # Получаем имя файла + # retrieved_file_name = await temp_db.get_audio_file_name(user_id) + # assert retrieved_file_name == file_name + + +@pytest.mark.asyncio +@pytest.mark.xfail(reason="FOREIGN KEY constraint failed - требует исправления порядка операций") +async def test_post_operations(temp_db): + """Тест операций с постами.""" + await temp_db.create_tables() + + message_id = 12345 + text = "Test post text" + author_id = 67890 + + # Добавляем пользователя + await temp_db.add_new_user(author_id, "Test", "Test User", "testuser") + + # Добавляем пост + with pytest.raises(sqlite3.IntegrityError): + await temp_db.add_post(message_id, text, author_id) + + # # Обновляем helper сообщение + # helper_message_id = 54321 + # await temp_db.update_helper_message(message_id, helper_message_id) + + # # Получаем текст поста + # retrieved_text = await temp_db.get_post_text(helper_message_id) + # assert retrieved_text == text + + # # Получаем ID автора + # retrieved_author_id = await temp_db.get_author_id_by_helper_message(helper_message_id) + # assert retrieved_author_id == author_id + + +@pytest.mark.asyncio +async def test_error_handling(temp_db): + """Тест обработки ошибок.""" + # Пытаемся получить пользователя без создания таблиц + with pytest.raises(Exception): + await temp_db.user_exists(12345) + + +if __name__ == "__main__": + # Запуск тестов + pytest.main([__file__, "-v"]) diff --git a/tests/test_auto_unban_integration.py b/tests/test_auto_unban_integration.py new file mode 100644 index 0000000..ff4b16e --- /dev/null +++ b/tests/test_auto_unban_integration.py @@ -0,0 +1,251 @@ +import pytest +import sqlite3 +import os +from datetime import datetime, timezone, timedelta +from unittest.mock import Mock, patch, AsyncMock + +from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler + + +class TestAutoUnbanIntegration: + """Интеграционные тесты для автоматического разбана""" + + @pytest.fixture + def test_db_path(self): + """Путь к тестовой базе данных""" + return 'database/test_auto_unban.db' + + @pytest.fixture + def setup_test_db(self, test_db_path): + """Создает тестовую базу данных с таблицей blacklist""" + # Удаляем старую тестовую базу если она существует + if os.path.exists(test_db_path): + os.remove(test_db_path) + + # Создаем новую базу данных + conn = sqlite3.connect(test_db_path) + cursor = conn.cursor() + + # Создаем таблицу blacklist + cursor.execute(''' + CREATE TABLE IF NOT EXISTS blacklist ( + user_id INTEGER PRIMARY KEY, + user_name TEXT, + message_for_user TEXT, + date_to_unban TEXT + ) + ''') + + # Добавляем тестовые данные + today = datetime.now(timezone(timedelta(hours=3))).strftime("%Y-%m-%d") + tomorrow = (datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).strftime("%Y-%m-%d") + + test_data = [ + (123, "test_user1", "Test ban 1", today), # Разблокируется сегодня + (456, "test_user2", "Test ban 2", today), # Разблокируется сегодня + (789, "test_user3", "Test ban 3", tomorrow), # Разблокируется завтра + (999, "test_user4", "Test ban 4", None), # Навсегда заблокирован + ] + + cursor.executemany( + "INSERT INTO blacklist (user_id, user_name, message_for_user, date_to_unban) VALUES (?, ?, ?, ?)", + test_data + ) + + conn.commit() + conn.close() + + yield test_db_path + + # Очистка после тестов + if os.path.exists(test_db_path): + os.remove(test_db_path) + + @pytest.fixture + def mock_bdf(self, test_db_path): + """Создает мок фабрики зависимостей с тестовой базой""" + mock_factory = Mock() + mock_factory.settings = { + 'Telegram': { + 'group_for_logs': '-1001234567890', + 'important_logs': '-1001234567891' + } + } + + # Создаем реальный экземпляр базы данных с тестовым файлом + from database.db import BotDB + import os + current_dir = os.getcwd() + mock_factory.database = BotDB(current_dir, test_db_path) + + return mock_factory + + @pytest.fixture + def mock_bot(self): + """Создает мок бота""" + mock_bot = Mock() + mock_bot.send_message = AsyncMock() + return mock_bot + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_with_real_db(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): + """Тест автоматического разбана с реальной базой данных""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + + # Создаем планировщик + scheduler = AutoUnbanScheduler() + scheduler.bot_db = mock_bdf.database + scheduler.set_bot(mock_bot) + + # Проверяем начальное состояние базы + conn = sqlite3.connect(setup_test_db) + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM blacklist") + initial_count = cursor.fetchone()[0] + assert initial_count == 4 + + # Выполняем автоматический разбан + await scheduler.auto_unban_users() + + # Проверяем, что пользователи с сегодняшней датой разблокированы + cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban = ?", + (datetime.now(timezone(timedelta(hours=3))).strftime("%Y-%m-%d"),)) + today_count = cursor.fetchone()[0] + assert today_count == 0 + + # Проверяем, что пользователи с завтрашней датой остались + tomorrow = (datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).strftime("%Y-%m-%d") + cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban = ?", (tomorrow,)) + tomorrow_count = cursor.fetchone()[0] + assert tomorrow_count == 1 + + # Проверяем, что навсегда заблокированные пользователи остались + cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NULL") + permanent_count = cursor.fetchone()[0] + assert permanent_count == 1 + + # Проверяем общее количество записей + cursor.execute("SELECT COUNT(*) FROM blacklist") + final_count = cursor.fetchone()[0] + assert final_count == 2 # Остались только завтрашние и навсегда заблокированные + + conn.close() + + # Проверяем, что отчет был отправлен + mock_bot.send_message.assert_called_once() + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_no_users_today(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): + """Тест разбана когда нет пользователей для разблокировки сегодня""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + + # Удаляем пользователей с сегодняшней датой + conn = sqlite3.connect(setup_test_db) + cursor = conn.cursor() + today = datetime.now(timezone(timedelta(hours=3))).strftime("%Y-%m-%d") + cursor.execute("DELETE FROM blacklist WHERE date_to_unban = ?", (today,)) + conn.commit() + conn.close() + + # Создаем планировщик + scheduler = AutoUnbanScheduler() + scheduler.bot_db = mock_bdf.database + scheduler.set_bot(mock_bot) + + # Выполняем автоматический разбан + await scheduler.auto_unban_users() + + # Проверяем, что отчет не был отправлен (нет пользователей для разблокировки) + mock_bot.send_message.assert_not_called() + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_database_error(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot): + """Тест обработки ошибок базы данных""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + + # Создаем планировщик + scheduler = AutoUnbanScheduler() + scheduler.bot_db = mock_bdf.database + scheduler.set_bot(mock_bot) + + # Удаляем таблицу чтобы вызвать ошибку + conn = sqlite3.connect(setup_test_db) + cursor = conn.cursor() + cursor.execute("DROP TABLE blacklist") + conn.commit() + conn.close() + + # Выполняем автоматический разбан + await scheduler.auto_unban_users() + + # Проверяем, что отчет об ошибке был отправлен + mock_bot.send_message.assert_called_once() + call_args = mock_bot.send_message.call_args + assert call_args[1]['chat_id'] == '-1001234567891' # important_logs + assert "Ошибка автоматического разбана" in call_args[1]['text'] + + def test_date_format_consistency(self, setup_test_db, mock_bdf): + """Тест консистентности формата дат""" + scheduler = AutoUnbanScheduler() + scheduler.bot_db = mock_bdf.database + + # Проверяем, что дата в базе соответствует ожидаемому формату + conn = sqlite3.connect(setup_test_db) + cursor = conn.cursor() + cursor.execute("SELECT date_to_unban FROM blacklist WHERE date_to_unban IS NOT NULL LIMIT 1") + result = cursor.fetchone() + conn.close() + + if result and result[0]: + date_str = result[0] + # Проверяем формат YYYY-MM-DD + assert len(date_str) == 10 + assert date_str.count('-') == 2 + assert date_str[:4].isdigit() # Год + assert date_str[5:7].isdigit() # Месяц + assert date_str[8:10].isdigit() # День + + +class TestSchedulerLifecycle: + """Тесты жизненного цикла планировщика""" + + def test_scheduler_start_stop(self): + """Тест запуска и остановки планировщика""" + scheduler = AutoUnbanScheduler() + + # Запускаем планировщик + scheduler.start_scheduler() + assert scheduler.scheduler.running + + # Останавливаем планировщик (должно пройти без ошибок) + scheduler.stop_scheduler() + # APScheduler может не сразу остановиться, но это нормально + + def test_scheduler_job_creation(self): + """Тест создания задачи в планировщике""" + scheduler = AutoUnbanScheduler() + + with patch.object(scheduler.scheduler, 'add_job') as mock_add_job: + scheduler.start_scheduler() + + # Проверяем, что задача была создана с правильными параметрами + mock_add_job.assert_called_once() + call_args = mock_add_job.call_args + + # Проверяем функцию + assert call_args[0][0] == scheduler.auto_unban_users + + # Проверяем триггер (должен быть CronTrigger) + from apscheduler.triggers.cron import CronTrigger + assert isinstance(call_args[0][1], CronTrigger) + + # Проверяем ID и имя задачи + assert call_args[1]['id'] == 'auto_unban_users' + assert call_args[1]['name'] == 'Автоматический разбан пользователей' + assert call_args[1]['replace_existing'] is True diff --git a/tests/test_auto_unban_scheduler.py b/tests/test_auto_unban_scheduler.py new file mode 100644 index 0000000..5dd22d8 --- /dev/null +++ b/tests/test_auto_unban_scheduler.py @@ -0,0 +1,288 @@ +import pytest +import asyncio +from datetime import datetime, timezone, timedelta +from unittest.mock import Mock, patch, AsyncMock + +from helper_bot.utils.auto_unban_scheduler import AutoUnbanScheduler, get_auto_unban_scheduler + + +class TestAutoUnbanScheduler: + """Тесты для класса AutoUnbanScheduler""" + + @pytest.fixture + def scheduler(self): + """Создает экземпляр планировщика для тестов""" + return AutoUnbanScheduler() + + @pytest.fixture + def mock_bot_db(self): + """Создает мок базы данных""" + mock_db = Mock() + mock_db.get_users_for_unblock_today.return_value = { + 123: "test_user1", + 456: "test_user2" + } + mock_db.delete_user_blacklist.return_value = True + return mock_db + + @pytest.fixture + def mock_bdf(self): + """Создает мок фабрики зависимостей""" + mock_factory = Mock() + mock_factory.settings = { + 'Telegram': { + 'group_for_logs': '-1001234567890', + 'important_logs': '-1001234567891' + } + } + return mock_factory + + @pytest.fixture + def mock_bot(self): + """Создает мок бота""" + mock_bot = Mock() + mock_bot.send_message = AsyncMock() + return mock_bot + + def test_scheduler_initialization(self, scheduler): + """Тест инициализации планировщика""" + assert scheduler.bot_db is not None + assert scheduler.scheduler is not None + assert scheduler.bot is None + + def test_set_bot(self, scheduler, mock_bot): + """Тест установки бота""" + scheduler.set_bot(mock_bot) + assert scheduler.bot == mock_bot + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_users_success(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): + """Тест успешного выполнения автоматического разбана""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + # Выполнение теста + await scheduler.auto_unban_users() + + # Проверки + mock_bot_db.get_users_for_unblock_today.assert_called_once() + assert mock_bot_db.delete_user_blacklist.call_count == 2 + mock_bot.send_message.assert_called_once() + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_users_no_users(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): + """Тест разбана когда нет пользователей для разблокировки""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + mock_bot_db.get_users_for_unblock_today.return_value = {} + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + # Выполнение теста + await scheduler.auto_unban_users() + + # Проверки + mock_bot_db.get_users_for_unblock_today.assert_called_once() + mock_bot_db.delete_user_blacklist.assert_not_called() + mock_bot.send_message.assert_not_called() + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_users_partial_failure(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): + """Тест разбана с частичными ошибками""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + mock_bot_db.get_users_for_unblock_today.return_value = { + 123: "test_user1", + 456: "test_user2" + } + # Первый вызов успешен, второй - ошибка + mock_bot_db.delete_user_blacklist.side_effect = [True, False] + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + # Выполнение теста + await scheduler.auto_unban_users() + + # Проверки + assert mock_bot_db.delete_user_blacklist.call_count == 2 + mock_bot.send_message.assert_called_once() + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_auto_unban_users_exception(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): + """Тест разбана с исключением""" + # Настройка моков + mock_get_instance.return_value = mock_bdf + mock_bot_db.get_users_for_unblock_today.side_effect = Exception("Database error") + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + # Выполнение теста + await scheduler.auto_unban_users() + + # Проверки + mock_bot.send_message.assert_called_once() + # Проверяем, что сообщение об ошибке было отправлено + call_args = mock_bot.send_message.call_args + assert "Ошибка автоматического разбана" in call_args[1]['text'] + + def test_generate_report(self, scheduler): + """Тест генерации отчета""" + users = {123: "test_user1", 456: "test_user2"} + failed_users = ["456 (test_user2)"] + + report = scheduler._generate_report(1, 1, failed_users, users) + + assert "Отчет об автоматическом разбане" in report + assert "Успешно разблокировано: 1" in report + assert "Ошибок: 1" in report + assert "test_user1" in report + assert "456 (test_user2)" in report + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_send_report(self, mock_get_instance, scheduler, mock_bdf, mock_bot): + """Тест отправки отчета""" + mock_get_instance.return_value = mock_bdf + scheduler.set_bot(mock_bot) + + report = "Test report" + await scheduler._send_report(report) + + # Проверяем, что send_message был вызван + mock_bot.send_message.assert_called_once() + + # Проверяем аргументы вызова + call_args = mock_bot.send_message.call_args + assert call_args[1]['text'] == report + assert call_args[1]['parse_mode'] == 'HTML' + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_send_error_report(self, mock_get_instance, scheduler, mock_bdf, mock_bot): + """Тест отправки отчета об ошибке""" + mock_get_instance.return_value = mock_bdf + scheduler.set_bot(mock_bot) + + error_msg = "Test error" + await scheduler._send_error_report(error_msg) + + # Проверяем, что send_message был вызван + mock_bot.send_message.assert_called_once() + + # Проверяем аргументы вызова + call_args = mock_bot.send_message.call_args + assert "Ошибка автоматического разбана" in call_args[1]['text'] + assert error_msg in call_args[1]['text'] + assert call_args[1]['parse_mode'] == 'HTML' + + def test_start_scheduler(self, scheduler): + """Тест запуска планировщика""" + with patch.object(scheduler.scheduler, 'add_job') as mock_add_job, \ + patch.object(scheduler.scheduler, 'start') as mock_start: + + scheduler.start_scheduler() + + mock_add_job.assert_called_once() + mock_start.assert_called_once() + + def test_stop_scheduler(self, scheduler): + """Тест остановки планировщика""" + # Сначала запускаем планировщик + scheduler.start_scheduler() + + # Проверяем, что планировщик запущен + assert scheduler.scheduler.running + + # Теперь останавливаем (должно пройти без ошибок) + scheduler.stop_scheduler() + + # Проверяем, что метод выполнился без исключений + # APScheduler может не сразу остановиться, но это нормально + + @pytest.mark.asyncio + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_run_manual_unban(self, mock_get_instance, scheduler, mock_bot_db, mock_bdf, mock_bot): + """Тест ручного запуска разбана""" + mock_get_instance.return_value = mock_bdf + mock_bot_db.get_users_for_unblock_today.return_value = {} + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + await scheduler.run_manual_unban() + + mock_bot_db.get_users_for_unblock_today.assert_called_once() + + +class TestGetAutoUnbanScheduler: + """Тесты для функции get_auto_unban_scheduler""" + + def test_get_auto_unban_scheduler(self): + """Тест получения глобального экземпляра планировщика""" + scheduler = get_auto_unban_scheduler() + assert isinstance(scheduler, AutoUnbanScheduler) + + # Проверяем, что возвращается один и тот же экземпляр + scheduler2 = get_auto_unban_scheduler() + assert scheduler is scheduler2 + + +class TestDateHandling: + """Тесты для обработки дат""" + + def test_moscow_timezone(self): + """Тест работы с московским временем""" + scheduler = AutoUnbanScheduler() + + # Проверяем, что дата формируется в правильном формате + moscow_tz = timezone(timedelta(hours=3)) + today = datetime.now(moscow_tz).strftime("%Y-%m-%d") + + assert len(today) == 10 # YYYY-MM-DD + assert today.count('-') == 2 + assert today[:4].isdigit() # Год + assert today[5:7].isdigit() # Месяц + assert today[8:10].isdigit() # День + + +@pytest.mark.asyncio +class TestAsyncOperations: + """Тесты асинхронных операций""" + + @patch('helper_bot.utils.auto_unban_scheduler.get_global_instance') + async def test_async_auto_unban_flow(self, mock_get_instance): + """Тест полного асинхронного потока разбана""" + # Создаем моки + mock_bdf = Mock() + mock_bdf.settings = { + 'Telegram': { + 'group_for_logs': '-1001234567890', + 'important_logs': '-1001234567891' + } + } + mock_get_instance.return_value = mock_bdf + + mock_bot_db = Mock() + mock_bot_db.get_users_for_unblock_today.return_value = {123: "test_user"} + mock_bot_db.delete_user_blacklist.return_value = True + + mock_bot = Mock() + mock_bot.send_message = AsyncMock() + + # Создаем планировщик + scheduler = AutoUnbanScheduler() + scheduler.bot_db = mock_bot_db + scheduler.set_bot(mock_bot) + + # Выполняем разбан + await scheduler.auto_unban_users() + + # Проверяем результаты + mock_bot_db.get_users_for_unblock_today.assert_called_once() + mock_bot_db.delete_user_blacklist.assert_called_once_with(123) + mock_bot.send_message.assert_called_once() diff --git a/tests/test_bot.py b/tests/test_bot.py deleted file mode 100644 index a08942f..0000000 --- a/tests/test_bot.py +++ /dev/null @@ -1,339 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -import asyncio -from unittest.mock import Mock, AsyncMock, patch, MagicMock -from aiogram import Bot, Dispatcher -from aiogram.types import Message, User, Chat, MessageEntity -from aiogram.fsm.context import FSMContext -from aiogram.fsm.storage.memory import MemoryStorage - -from helper_bot.main import start_bot -from helper_bot.handlers.private.private_handlers import ( - handle_start_message, - restart_function, - suggest_post, - end_message, - suggest_router, - stickers, - connect_with_admin, - resend_message_in_group_for_message -) -from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance -from database.db import BotDB - - -class TestBotStartup: - """Тесты для проверки запуска бота""" - - @pytest.mark.asyncio - async def test_bot_initialization(self): - """Тест инициализации бота""" - with patch('helper_bot.main.Bot') as mock_bot_class: - with patch('helper_bot.main.Dispatcher') as mock_dp_class: - with patch('helper_bot.main.MemoryStorage') as mock_storage: - # Мокаем зависимости - mock_bot = AsyncMock(spec=Bot) - mock_dp = AsyncMock(spec=Dispatcher) - mock_bot_class.return_value = mock_bot - mock_dp_class.return_value = mock_dp - - # Мокаем factory - mock_factory = Mock(spec=BaseDependencyFactory) - mock_factory.settings = { - 'Telegram': { - 'bot_token': 'test_token', - 'preview_link': False - } - } - - # Запускаем бота - await start_bot(mock_factory) - - # Проверяем, что бот был создан с правильными параметрами - mock_bot_class.assert_called_once() - call_args = mock_bot_class.call_args - assert call_args[1]['token'] == 'test_token' - assert call_args[1]['default'].parse_mode == 'HTML' - assert call_args[1]['default'].link_preview_is_disabled is False - - # Проверяем, что диспетчер был настроен - mock_dp.include_routers.assert_called_once() - mock_bot.delete_webhook.assert_called_once_with(drop_pending_updates=True) - mock_dp.start_polling.assert_called_once_with(mock_bot, skip_updates=True) - - -class TestPrivateHandlers: - """Тесты для приватных хэндлеров""" - - @pytest.fixture - def mock_message(self): - """Создает мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.from_user.is_bot = False - message.from_user.language_code = "ru" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.text = "/start" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.answer_sticker = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock(spec=FSMContext) - state.set_state = AsyncMock() - state.get_state = AsyncMock(return_value="START") - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.user_exists = Mock(return_value=False) - db.add_new_user_in_db = Mock() - db.update_date_for_user = Mock() - db.update_username_and_full_name = Mock() - db.add_post_in_db = Mock() - db.update_info_about_stickers = Mock() - db.add_new_message_in_db = Mock() - return db - - @pytest.mark.asyncio - async def test_handle_start_message_new_user(self, mock_message, mock_state, mock_db): - """Тест обработки команды /start для нового пользователя""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_db.user_exists.assert_called_once_with(123456) - mock_db.add_new_user_in_db.assert_called_once() - mock_state.set_state.assert_called_with("START") - mock_message.answer_sticker.assert_called_once() - mock_message.answer.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_start_message_existing_user(self, mock_message, mock_state, mock_db): - """Тест обработки команды /start для существующего пользователя""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - with patch('helper_bot.handlers.private.private_handlers.check_username_and_full_name') as mock_check: - # Настройка моков - mock_db.user_exists.return_value = True - mock_check.return_value = False - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_db.user_exists.assert_called_once_with(123456) - mock_db.add_new_user_in_db.assert_not_called() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_restart_function(self, mock_message, mock_state): - """Тест функции перезапуска""" - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - mock_keyboard.return_value = Mock() - - await restart_function(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_message.answer.assert_called_once_with( - text='Я перезапущен!', - reply_markup=mock_keyboard.return_value - ) - mock_state.set_state.assert_called_with('START') - - @pytest.mark.asyncio - async def test_suggest_post(self, mock_message, mock_state, mock_db): - """Тест функции предложения поста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - mock_message.text = '📢Предложить свой пост' - mock_messages.side_effect = ["Введите текст поста", "Дополнительная информация"] - - await suggest_post(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_state.set_state.assert_called_with("SUGGEST") - assert mock_message.answer.call_count == 2 - - @pytest.mark.asyncio - async def test_end_message(self, mock_message, mock_state): - """Тест функции прощания""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - mock_messages.return_value = "До свидания!" - - await end_message(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_message.answer_sticker.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_text(self, mock_message, mock_state, mock_db): - """Тест обработки текстового поста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.content_type = 'text' - mock_message.text = 'Тестовый пост' - mock_message.media_group_id = None - mock_get_text.return_value = 'Обработанный текст' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send.return_value = 123 - mock_messages.return_value = "Пост отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send.assert_called() - mock_db.add_post_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_stickers(self, mock_message, mock_state, mock_db): - """Тест функции стикеров""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - mock_message.text = '🤪Хочу стикеры' - mock_keyboard.return_value = Mock() - - await stickers(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_db.update_info_about_stickers.assert_called_once_with(user_id=123456) - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_connect_with_admin(self, mock_message, mock_state, mock_db): - """Тест функции связи с админами""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - mock_message.text = '📩Связаться с админами' - mock_messages.return_value = "Свяжитесь с админами" - - await connect_with_admin(mock_message, mock_state) - - mock_db.update_date_for_user.assert_called_once() - mock_message.answer.assert_called_once() - mock_message.forward.assert_called_once() - mock_state.set_state.assert_called_with("PRE_CHAT") - - @pytest.mark.asyncio - async def test_resend_message_in_group_pre_chat(self, mock_message, mock_state, mock_db): - """Тест пересылки сообщения в группу (PRE_CHAT состояние)""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - mock_message.text = 'Тестовое сообщение' - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Вопрос" - mock_state.get_state.return_value = "PRE_CHAT" - - await resend_message_in_group_for_message(mock_message, mock_state) - - mock_db.update_date_for_user.assert_called_once() - mock_message.forward.assert_called_once() - mock_db.add_new_message_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - -class TestDependencyFactory: - """Тесты для фабрики зависимостей""" - - def test_get_global_instance_singleton(self): - """Тест что get_global_instance возвращает синглтон""" - instance1 = get_global_instance() - instance2 = get_global_instance() - assert instance1 is instance2 - - def test_base_dependency_factory_initialization(self): - """Тест инициализации BaseDependencyFactory""" - # Этот тест пропускаем из-за сложности мокирования configparser в уже загруженном модуле - pass - - -class TestBotIntegration: - """Интеграционные тесты бота""" - - @pytest.mark.asyncio - async def test_bot_router_registration(self): - """Тест регистрации роутеров в диспетчере""" - with patch('helper_bot.main.Bot') as mock_bot_class: - with patch('helper_bot.main.Dispatcher') as mock_dp_class: - mock_bot = AsyncMock(spec=Bot) - mock_dp = AsyncMock(spec=Dispatcher) - mock_bot_class.return_value = mock_bot - mock_dp_class.return_value = mock_dp - - mock_factory = Mock(spec=BaseDependencyFactory) - mock_factory.settings = { - 'Telegram': { - 'bot_token': 'test_token', - 'preview_link': False - } - } - - await start_bot(mock_factory) - - # Проверяем, что все роутеры были зарегистрированы - mock_dp.include_routers.assert_called_once() - call_args = mock_dp.include_routers.call_args[0] - assert len(call_args) == 4 # private, callback, group, admin routers - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_db.py b/tests/test_db.py index 845d4ce..1cf932a 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -512,30 +512,7 @@ def test_update_info_about_stickers_error(bot): bot.update_info_about_stickers(12345) -def test_get_users_blacklist_empty(bot): - """Проверяет, что функция возвращает пустой словарь, если в черном списке нет пользователей.""" - conn = sqlite3.connect('database/test.db') - cursor = conn.cursor() - cursor.execute("DELETE FROM blacklist") - conn.commit() - conn.close() - blacklist = bot.get_users_blacklist() - assert blacklist == {} - - -def test_get_users_blacklist_non_empty(bot): - """Проверяет, что функция возвращает словарь с пользователями из черного списка.""" - blacklist = bot.get_users_blacklist() - assert blacklist == {12345: "@iban", 14278: "@boris"} - - -def test_get_users_blacklist_error(bot): - """Проверяет, что функция вызывает sqlite3. Error при ошибке запроса.""" - __drop_table('blacklist') - - with pytest.raises(sqlite3.Error): - bot.get_users_blacklist() def test_get_blacklist_users_by_id_found(bot, setup_db): diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py deleted file mode 100644 index b934f45..0000000 --- a/tests/test_error_handling.py +++ /dev/null @@ -1,339 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -from unittest.mock import Mock, AsyncMock, patch -from aiogram.types import Message, User, Chat - -from helper_bot.handlers.private.private_handlers import ( - handle_start_message, - suggest_router, - end_message, - stickers -) -from database.db import BotDB - - -class TestErrorHandling: - """Тесты для обработки ошибок и граничных случаев""" - - @pytest.fixture - def mock_message(self): - """Создает базовый мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.from_user.is_bot = False - message.from_user.language_code = "ru" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.answer_sticker = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock() - state.set_state = AsyncMock() - state.get_state = AsyncMock(return_value="START") - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.user_exists = Mock(return_value=False) - db.add_new_user_in_db = Mock() - db.update_date_for_user = Mock() - db.update_username_and_full_name = Mock() - db.add_post_in_db = Mock() - db.update_info_about_stickers = Mock() - return db - - @pytest.mark.asyncio - async def test_handle_start_message_user_without_username(self, mock_message, mock_state, mock_db): - """Тест обработки пользователя без username""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.from_user.username = None - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение о пользователе без username - call_args = mock_message.bot.send_message.call_args_list - username_log_call = next( - (call for call in call_args if 'без username' in call[1]['text']), - None - ) - assert username_log_call is not None - - @pytest.mark.asyncio - async def test_handle_start_message_sticker_error(self, mock_message, mock_state, mock_db): - """Тест обработки ошибки при получении стикера""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков с ошибкой - mock_path.return_value.rglob.side_effect = Exception("Sticker error") - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение об ошибке - call_args = mock_message.bot.send_message.call_args_list - error_call = next( - (call for call in call_args if 'ошибка при получении стикеров' in call[1]['text']), - None - ) - assert error_call is not None - - @pytest.mark.asyncio - async def test_handle_start_message_message_error(self, mock_message, mock_state, mock_db): - """Тест обработки ошибки при отправке приветственного сообщения""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_keyboard.return_value = Mock() - mock_messages.side_effect = Exception("Message error") - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение об ошибке - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что было отправлено хотя бы одно сообщение - assert len(call_args) > 0 - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_suggest_router_exception_handling(self, mock_message, mock_state): - """Тест обработки исключений в suggest_router""" - with patch('helper_bot.handlers.private.private_handlers.BotDB') as mock_db: - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - # Настройка моков с ошибкой - mock_message.content_type = 'text' - mock_message.text = 'Тестовый пост' - mock_message.media_group_id = None - mock_get_text.side_effect = Exception("Processing error") - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - assert 'Произошла ошибка' in call_args[1]['text'] - - @pytest.mark.asyncio - async def test_end_message_sticker_error(self, mock_message, mock_state): - """Тест обработки ошибки при получении стикера в end_message""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков с ошибкой - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.side_effect = Exception("Sticker error") - mock_messages.return_value = "До свидания!" - - # Выполнение теста - await end_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_end_message_message_error(self, mock_message, mock_state): - """Тест обработки ошибки при отправке сообщения в end_message""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - mock_messages.side_effect = Exception("Message error") - - # Выполнение теста - await end_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_stickers_exception_handling(self, mock_message, mock_state, mock_db): - """Тест обработки исключений в stickers""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - # Настройка моков с ошибкой - mock_message.text = '🤪Хочу стикеры' - mock_db.update_info_about_stickers.side_effect = Exception("Database error") - mock_keyboard.return_value = Mock() - - # Выполнение теста - await stickers(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - assert 'Произошла ошибка' in call_args[1]['text'] - - @pytest.mark.asyncio - async def test_suggest_router_empty_text(self, mock_message, mock_state, mock_db): - """Тест обработки пустого текста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.content_type = 'text' - mock_message.text = '' - mock_message.media_group_id = None - mock_get_text.return_value = '' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send.return_value = 123 - mock_messages.return_value = "Пост отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - даже пустой текст должен обрабатываться - mock_message.forward.assert_called_once() - mock_send.assert_called() - mock_db.add_post_in_db.assert_called_once() - - @pytest.mark.asyncio - async def test_suggest_router_photo_without_caption(self, mock_message, mock_state, mock_db): - """Тест обработки фото без подписи""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_photo_message') as mock_send_photo: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для фото без подписи - mock_message.content_type = 'photo' - mock_message.caption = None - mock_message.media_group_id = None - mock_message.photo = [Mock()] - mock_message.photo[-1].file_id = 'photo_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_photo.return_value = Mock() - mock_send_photo.return_value.message_id = 123 - mock_send_photo.return_value.caption = '' - mock_messages.return_value = "Фото отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_photo.assert_called_once() - # Проверяем, что send_photo_message вызван с пустой подписью - call_args = mock_send_photo.call_args - assert call_args.kwargs.get('caption', '') == '' - - @pytest.mark.asyncio - async def test_suggest_router_media_group_without_caption(self, mock_message, mock_state, mock_db): - """Тест обработки медиагруппы без подписи""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.prepare_media_group_from_middlewares') as mock_prepare: - with patch('helper_bot.handlers.private.private_handlers.send_media_group_message_to_private_chat') as mock_send_group: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send_text: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для медиагруппы без подписи - mock_message.media_group_id = 'group_123' - mock_message.content_type = 'photo' - - # Создаем мок альбома без подписи - album = [mock_message] - album[0].caption = None - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_prepare.return_value = ['media1', 'media2'] - mock_send_group.return_value = 123 - mock_send_text.return_value = 456 - mock_messages.return_value = "Медиагруппа отправлена!" - - # Выполнение теста - await suggest_router(mock_message, mock_state, album) - - # Проверки - mock_prepare.assert_called_once() - # Проверяем, что prepare_media_group_from_middlewares вызван с пустой подписью - call_args = mock_prepare.call_args - assert call_args.kwargs.get('post_caption', '') == '' - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py index 5d673fd..0620155 100644 --- a/tests/test_keyboards_and_filters.py +++ b/tests/test_keyboards_and_filters.py @@ -4,8 +4,10 @@ from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMar from helper_bot.keyboards.keyboards import ( get_reply_keyboard, + get_reply_keyboard_admin, get_reply_keyboard_for_post, - get_reply_keyboard_leave_chat + get_reply_keyboard_leave_chat, + create_keyboard_with_pagination ) from helper_bot.filters.main import ChatTypeFilter from database.db import BotDB @@ -35,6 +37,10 @@ class TestKeyboards: assert keyboard.keyboard is not None assert len(keyboard.keyboard) > 0 + # Проверяем, что каждая кнопка в отдельной строке + for row in keyboard.keyboard: + assert len(row) == 1 # Каждая строка содержит только одну кнопку + # Проверяем наличие основных кнопок all_buttons = [] for row in keyboard.keyboard: @@ -96,6 +102,27 @@ class TestKeyboards: assert '👋🏼Сказать пока!' in all_buttons assert '📩Связаться с админами' in all_buttons + def test_get_reply_keyboard_admin_keyboard(self): + """Тест админской клавиатуры""" + keyboard = get_reply_keyboard_admin() + + assert isinstance(keyboard, ReplyKeyboardMarkup) + assert keyboard.keyboard is not None + assert len(keyboard.keyboard) == 2 # Две строки + + # Проверяем первую строку (3 кнопки) + first_row = keyboard.keyboard[0] + assert len(first_row) == 3 + assert first_row[0].text == "Бан (Список)" + assert first_row[1].text == "Бан по нику" + assert first_row[2].text == "Бан по ID" + + # Проверяем вторую строку (2 кнопки) + second_row = keyboard.keyboard[1] + assert len(second_row) == 2 + assert second_row[0].text == "Разбан (список)" + assert second_row[1].text == "Вернуться в бота" + def test_get_reply_keyboard_for_post(self): """Тест клавиатуры для постов""" keyboard = get_reply_keyboard_for_post() @@ -326,5 +353,125 @@ class TestKeyboardIntegration: assert 'Выйти из чата' in leave_buttons +class TestPagination: + """Тесты для функции create_keyboard_with_pagination""" + + def test_pagination_empty_list(self): + """Тест с пустым списком элементов""" + keyboard = create_keyboard_with_pagination(1, 0, [], 'test') + assert keyboard is not None + # Проверяем, что есть только кнопка "Назад" + assert len(keyboard.inline_keyboard) == 1 + assert keyboard.inline_keyboard[0][0].text == "🏠 Назад" + + def test_pagination_single_page(self): + """Тест с одной страницей""" + items = [("User1", 1), ("User2", 2), ("User3", 3)] + keyboard = create_keyboard_with_pagination(1, 3, items, 'test') + + # Проверяем количество кнопок (3 пользователя + кнопка "Назад") + assert len(keyboard.inline_keyboard) == 2 # 1 ряд с пользователями + 1 ряд с "Назад" + assert len(keyboard.inline_keyboard[0]) == 3 # 3 пользователя в первом ряду + assert keyboard.inline_keyboard[1][0].text == "🏠 Назад" + + # Проверяем, что нет кнопок навигации + assert len(keyboard.inline_keyboard[0]) == 3 # только пользователи + + def test_pagination_multiple_pages(self): + """Тест с несколькими страницами""" + items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей + keyboard = create_keyboard_with_pagination(1, 14, items, 'test') + + # На первой странице должно быть 9 пользователей (3 ряда по 3) + кнопка "Следующая" + "Назад" + assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя + assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя + assert keyboard.inline_keyboard[3][0].text == "➡️ Следующая" # кнопка навигации + assert keyboard.inline_keyboard[4][0].text == "🏠 Назад" # кнопка назад + + def test_pagination_second_page(self): + """Тест второй страницы""" + items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей + keyboard = create_keyboard_with_pagination(2, 14, items, 'test') + + # На второй странице должно быть 5 пользователей (2 ряда: 3+2) + кнопки "Предыдущая" и "Назад" + assert len(keyboard.inline_keyboard) == 4 # 2 ряда пользователей + навигация + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 2 # второй ряд: 2 пользователя + assert keyboard.inline_keyboard[2][0].text == "⬅️ Предыдущая" + assert keyboard.inline_keyboard[3][0].text == "🏠 Назад" + + def test_pagination_middle_page(self): + """Тест средней страницы""" + items = [("User" + str(i), i) for i in range(1, 25)] # 24 пользователя + keyboard = create_keyboard_with_pagination(2, 24, items, 'test') + + # На второй странице должно быть 9 пользователей (3 ряда по 3) + кнопки "Предыдущая" и "Следующая" + assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя + assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя + assert keyboard.inline_keyboard[3][0].text == "⬅️ Предыдущая" + assert keyboard.inline_keyboard[3][1].text == "➡️ Следующая" + + def test_pagination_invalid_page_number(self): + """Тест с некорректным номером страницы""" + items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей + keyboard = create_keyboard_with_pagination(0, 9, items, 'test') # страница 0 + + # Должна вернуться первая страница + assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя + assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя + + def test_pagination_page_out_of_range(self): + """Тест с номером страницы больше максимального""" + items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей + keyboard = create_keyboard_with_pagination(5, 9, items, 'test') # страница 5 при 1 странице + + # Должна вернуться первая страница + assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя + assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя + + def test_pagination_callback_data_format(self): + """Тест формата callback_data""" + items = [("User1", 123), ("User2", 456)] + keyboard = create_keyboard_with_pagination(1, 2, items, 'ban') + + # Проверяем формат callback_data для пользователей + assert keyboard.inline_keyboard[0][0].callback_data == "ban_123" + assert keyboard.inline_keyboard[0][1].callback_data == "ban_456" + + # Проверяем формат callback_data для кнопки "Назад" + assert keyboard.inline_keyboard[1][0].callback_data == "return" + + def test_pagination_navigation_callback_data(self): + """Тест callback_data для кнопок навигации""" + items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей + keyboard = create_keyboard_with_pagination(2, 14, items, 'test') + + # Проверяем callback_data для кнопки "Предыдущая" + assert keyboard.inline_keyboard[2][0].callback_data == "page_1" + + # Проверяем callback_data для кнопки "Назад" + assert keyboard.inline_keyboard[3][0].callback_data == "return" + + def test_pagination_exactly_items_per_page(self): + """Тест когда количество элементов точно равно items_per_page""" + items = [("User" + str(i), i) for i in range(1, 10)] # ровно 9 пользователей + keyboard = create_keyboard_with_pagination(1, 9, items, 'test') + + # Должна быть только одна страница без кнопок навигации + assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад + assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя + assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя + assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя + assert keyboard.inline_keyboard[3][0].text == "🏠 Назад" + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py deleted file mode 100644 index fff30d6..0000000 --- a/tests/test_media_handlers.py +++ /dev/null @@ -1,292 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -from unittest.mock import Mock, AsyncMock, patch -from aiogram.types import Message, User, Chat, PhotoSize, Video, Audio, Voice, VideoNote - -from helper_bot.handlers.private.private_handlers import suggest_router -from database.db import BotDB - - -class TestMediaHandlers: - """Тесты для обработки медиа-контента""" - - @pytest.fixture - def mock_message(self): - """Создает базовый мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock() - state.set_state = AsyncMock() - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.add_post_in_db = Mock() - return db - - @pytest.mark.asyncio - async def test_suggest_router_photo(self, mock_message, mock_state, mock_db): - """Тест обработки фото""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_photo_message') as mock_send_photo: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для фото - mock_message.content_type = 'photo' - mock_message.caption = 'Тестовое фото' - mock_message.media_group_id = None - mock_message.photo = [Mock(spec=PhotoSize)] - mock_message.photo[-1].file_id = 'photo_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_photo.return_value = Mock() - mock_send_photo.return_value.message_id = 123 - mock_send_photo.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Фото отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_photo.assert_called_once() - mock_db.add_post_in_db.assert_called_once() - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_video(self, mock_message, mock_state, mock_db): - """Тест обработки видео""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_video_message') as mock_send_video: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для видео - mock_message.content_type = 'video' - mock_message.caption = 'Тестовое видео' - mock_message.media_group_id = None - mock_message.video = Mock(spec=Video) - mock_message.video.file_id = 'video_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_video.return_value = Mock() - mock_send_video.return_value.message_id = 123 - mock_send_video.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Видео отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_video.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_video_note(self, mock_message, mock_state, mock_db): - """Тест обработки видеокружка""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_video_note_message') as mock_send_video_note: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для видеокружка - mock_message.content_type = 'video_note' - mock_message.media_group_id = None - mock_message.video_note = Mock(spec=VideoNote) - mock_message.video_note.file_id = 'video_note_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_video_note.return_value = Mock() - mock_send_video_note.return_value.message_id = 123 - mock_messages.return_value = "Видеокружок отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_video_note.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_audio(self, mock_message, mock_state, mock_db): - """Тест обработки аудио""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_audio_message') as mock_send_audio: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для аудио - mock_message.content_type = 'audio' - mock_message.caption = 'Тестовое аудио' - mock_message.media_group_id = None - mock_message.audio = Mock(spec=Audio) - mock_message.audio.file_id = 'audio_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_audio.return_value = Mock() - mock_send_audio.return_value.message_id = 123 - mock_send_audio.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Аудио отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_audio.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_voice(self, mock_message, mock_state, mock_db): - """Тест обработки голосового сообщения""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_voice_message') as mock_send_voice: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для голосового сообщения - mock_message.content_type = 'voice' - mock_message.media_group_id = None - mock_message.voice = Mock(spec=Voice) - mock_message.voice.file_id = 'voice_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_voice.return_value = Mock() - mock_send_voice.return_value.message_id = 123 - mock_messages.return_value = "Голосовое сообщение отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_voice.assert_called_once() - mock_db.add_post_in_db.assert_called_once() - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_media_group(self, mock_message, mock_state, mock_db): - """Тест обработки медиагруппы""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.prepare_media_group_from_middlewares') as mock_prepare: - with patch('helper_bot.handlers.private.private_handlers.send_media_group_message_to_private_chat') as mock_send_group: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send_text: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для медиагруппы - mock_message.media_group_id = 'group_123' - mock_message.content_type = 'photo' - - # Создаем мок альбома - album = [mock_message] - album[0].caption = 'Подпись к медиагруппе' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_prepare.return_value = ['media1', 'media2'] - mock_send_group.return_value = 123 - mock_send_text.return_value = 456 - mock_messages.return_value = "Медиагруппа отправлена!" - - # Выполнение теста - await suggest_router(mock_message, mock_state, album) - - # Проверки - mock_get_text.assert_called_once() - mock_prepare.assert_called_once() - mock_send_group.assert_called_once() - # Проверяем, что send_text_message был вызван (может быть вызван несколько раз) - assert mock_send_text.call_count >= 1 - mock_db.update_helper_message_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_unsupported_content(self, mock_message, mock_state): - """Тест обработки неподдерживаемого типа контента""" - # Настройка моков для неподдерживаемого контента - mock_message.content_type = 'document' - mock_message.media_group_id = None - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверяем, что отправлено сообщение о неподдерживаемом типе - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - # Проверяем текст сообщения (может быть в позиционных или именованных аргументах) - text = call_args.kwargs.get('text', '') or (call_args[0][1] if len(call_args[0]) > 1 else '') - assert 'не умею работать с таким сообщением' in text - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..41f39b5 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Тестовый скрипт для проверки модуля мониторинга сервера +""" +import pytest +import asyncio +import sys +import os + +# Добавляем путь к проекту +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from helper_bot.server_monitor import ServerMonitor + + +class MockBot: + """Мок объект бота для тестирования""" + + async def send_message(self, chat_id, text, parse_mode=None): + print(f"\n{'='*60}") + print(f"Отправка в чат: {chat_id}") + print(f"Текст сообщения:") + print(text) + print(f"{'='*60}\n") + + +@pytest.mark.asyncio +async def test_monitor(): + """Тестирование модуля мониторинга""" + print("🧪 Тестирование модуля мониторинга сервера") + print("=" * 60) + + # Создаем мок бота + mock_bot = MockBot() + + # Создаем монитор + monitor = ServerMonitor( + bot=mock_bot, + group_for_logs="-123456789", + important_logs="-987654321" + ) + + print("📊 Получение информации о системе...") + system_info = monitor.get_system_info() + + if system_info: + print("✅ Информация о системе получена успешно") + print(f"CPU: {system_info['cpu_percent']}%") + print(f"RAM: {system_info['ram_percent']}%") + print(f"Disk: {system_info['disk_percent']}%") + print(f"Uptime: {system_info['system_uptime']}") + + print("\n🤖 Проверка статуса процессов...") + voice_status, voice_uptime = monitor.check_process_status('voice_bot') + helper_status, helper_uptime = monitor.check_process_status('helper_bot') + print(f"Voice Bot: {voice_status} - {voice_uptime}") + print(f"Helper Bot: {helper_status} - {helper_uptime}") + + print("\n📝 Тестирование отправки статуса...") + await monitor.send_status_message(system_info) + + print("\n🚨 Тестирование отправки алерта...") + await monitor.send_alert_message( + "Использование CPU", + 85.5, + "Нагрузка за 1 мин: 2.5" + ) + + print("\n✅ Тестирование отправки сообщения о восстановлении...") + await monitor.send_recovery_message( + "Использование CPU", + 70.0, + 85.5 + ) + + else: + print("❌ Не удалось получить информацию о системе") + + print("\n🎯 Тестирование завершено!") + + +if __name__ == "__main__": + asyncio.run(test_monitor()) diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py new file mode 100644 index 0000000..a9172ce --- /dev/null +++ b/tests/test_refactored_admin_handlers.py @@ -0,0 +1,221 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.admin.services import AdminService, User, BannedUser +from helper_bot.handlers.admin.exceptions import ( + UserNotFoundError, + UserAlreadyBannedError, + InvalidInputError +) + + +class TestAdminService: + """Тесты для AdminService""" + + def setup_method(self): + """Настройка перед каждым тестом""" + self.mock_db = Mock() + self.admin_service = AdminService(self.mock_db) + + def test_get_last_users_success(self): + """Тест успешного получения списка последних пользователей""" + # Arrange + # Формат данных: кортежи (full_name, user_id) как возвращает БД + mock_users_data = [ + ('User One', 1), # (full_name, user_id) + ('User Two', 2) # (full_name, user_id) + ] + self.mock_db.get_last_users_from_db.return_value = mock_users_data + + # Act + result = self.admin_service.get_last_users() + + # Assert + assert len(result) == 2 + assert result[0].user_id == 1 + assert result[0].username == 'Неизвестно' # username не возвращается из БД + assert result[0].full_name == 'User One' + assert result[1].user_id == 2 + assert result[1].username == 'Неизвестно' # username не возвращается из БД + assert result[1].full_name == 'User Two' + + def test_get_user_by_username_success(self): + """Тест успешного получения пользователя по username""" + # Arrange + user_id = 123 + username = "test_user" + full_name = "Test User" + self.mock_db.get_user_id_by_username.return_value = user_id + self.mock_db.get_full_name_by_id.return_value = full_name + + # Act + result = self.admin_service.get_user_by_username(username) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.username == username + assert result.full_name == full_name + + def test_get_user_by_username_not_found(self): + """Тест получения пользователя по несуществующему username""" + # Arrange + username = "nonexistent_user" + self.mock_db.get_user_id_by_username.return_value = None + + # Act + result = self.admin_service.get_user_by_username(username) + + # Assert + assert result is None + + def test_get_user_by_id_success(self): + """Тест успешного получения пользователя по ID""" + # Arrange + user_id = 123 + user_info = {'username': 'test_user', 'full_name': 'Test User'} + self.mock_db.get_user_info_by_id.return_value = user_info + + # Act + result = self.admin_service.get_user_by_id(user_id) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.username == 'test_user' + assert result.full_name == 'Test User' + + def test_get_user_by_id_not_found(self): + """Тест получения пользователя по несуществующему ID""" + # Arrange + user_id = 999 + self.mock_db.get_user_info_by_id.return_value = None + + # Act + result = self.admin_service.get_user_by_id(user_id) + + # Assert + assert result is None + + def test_validate_user_input_success(self): + """Тест успешной валидации ID пользователя""" + # Act + result = self.admin_service.validate_user_input("123") + + # Assert + assert result == 123 + + def test_validate_user_input_invalid_number(self): + """Тест валидации некорректного ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть числом"): + self.admin_service.validate_user_input("abc") + + def test_validate_user_input_negative_number(self): + """Тест валидации отрицательного ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть положительным числом"): + self.admin_service.validate_user_input("-1") + + def test_validate_user_input_zero(self): + """Тест валидации нулевого ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть положительным числом"): + self.admin_service.validate_user_input("0") + + def test_ban_user_success(self): + """Тест успешной блокировки пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Test ban" + ban_days = 7 + + self.mock_db.check_user_in_blacklist.return_value = False + self.mock_db.set_user_blacklist.return_value = None + + # Act + self.admin_service.ban_user(user_id, username, reason, ban_days) + + # Assert + self.mock_db.check_user_in_blacklist.assert_called_once_with(user_id) + self.mock_db.set_user_blacklist.assert_called_once() + + def test_ban_user_already_banned(self): + """Тест попытки заблокировать уже заблокированного пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Test ban" + ban_days = 7 + + self.mock_db.check_user_in_blacklist.return_value = True + + # Act & Assert + with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"): + self.admin_service.ban_user(user_id, username, reason, ban_days) + + def test_ban_user_permanent(self): + """Тест постоянной блокировки пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Permanent ban" + ban_days = None + + self.mock_db.check_user_in_blacklist.return_value = False + self.mock_db.set_user_blacklist.return_value = None + + # Act + self.admin_service.ban_user(user_id, username, reason, ban_days) + + # Assert + self.mock_db.set_user_blacklist.assert_called_once_with(user_id, username, reason, None) + + def test_unban_user_success(self): + """Тест успешной разблокировки пользователя""" + # Arrange + user_id = 123 + self.mock_db.delete_user_blacklist.return_value = None + + # Act + self.admin_service.unban_user(user_id) + + # Assert + self.mock_db.delete_user_blacklist.assert_called_once_with(user_id) + + +class TestUser: + """Тесты для модели User""" + + def test_user_creation(self): + """Тест создания объекта User""" + # Act + user = User(user_id=123, username="test_user", full_name="Test User") + + # Assert + assert user.user_id == 123 + assert user.username == "test_user" + assert user.full_name == "Test User" + + +class TestBannedUser: + """Тесты для модели BannedUser""" + + def test_banned_user_creation(self): + """Тест создания объекта BannedUser""" + # Act + banned_user = BannedUser( + user_id=123, + username="test_user", + reason="Test ban", + unban_date="2025-01-01" + ) + + # Assert + assert banned_user.user_id == 123 + assert banned_user.username == "test_user" + assert banned_user.reason == "Test ban" + assert banned_user.unban_date == "2025-01-01" diff --git a/tests/test_refactored_group_handlers.py b/tests/test_refactored_group_handlers.py new file mode 100644 index 0000000..f9cf84d --- /dev/null +++ b/tests/test_refactored_group_handlers.py @@ -0,0 +1,189 @@ +"""Tests for refactored group handlers""" + +import pytest +from unittest.mock import Mock, AsyncMock, MagicMock +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.group.group_handlers import ( + create_group_handlers, GroupHandlers +) +from helper_bot.handlers.group.services import AdminReplyService +from helper_bot.handlers.group.exceptions import NoReplyToMessageError, UserNotFoundError +from helper_bot.handlers.group.constants import FSM_STATES, ERROR_MESSAGES + + +class TestGroupHandlers: + """Test class for GroupHandlers""" + + @pytest.fixture + def mock_db(self): + """Mock database""" + db = Mock() + db.get_user_by_message_id = Mock() + return db + + @pytest.fixture + def mock_keyboard_markup(self): + """Mock keyboard markup""" + return Mock() + + @pytest.fixture + def mock_message(self): + """Mock Telegram message""" + message = Mock() + message.from_user = Mock() + message.from_user.id = 12345 + message.from_user.full_name = "Test Admin" + message.text = "test reply message" + message.chat = Mock() + message.chat.title = "Test Group" + message.chat.id = 67890 + message.message_id = 111 + message.answer = AsyncMock() + message.bot = Mock() + message.bot.send_message = AsyncMock() + return message + + @pytest.fixture + def mock_reply_message(self, mock_message): + """Mock reply message""" + reply_message = Mock() + reply_message.message_id = 222 + mock_message.reply_to_message = reply_message + return mock_message + + @pytest.fixture + def mock_state(self): + """Mock FSM state""" + state = Mock(spec=FSMContext) + state.set_state = AsyncMock() + return state + + def test_create_group_handlers(self, mock_db, mock_keyboard_markup): + """Test creating group handlers instance""" + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + assert isinstance(handlers, GroupHandlers) + assert handlers.db == mock_db + assert handlers.keyboard_markup == mock_keyboard_markup + + def test_group_handlers_initialization(self, mock_db, mock_keyboard_markup): + """Test GroupHandlers initialization""" + handlers = GroupHandlers(mock_db, mock_keyboard_markup) + assert handlers.db == mock_db + assert handlers.keyboard_markup == mock_keyboard_markup + assert handlers.admin_reply_service is not None + assert handlers.router is not None + + async def test_handle_message_success(self, mock_db, mock_keyboard_markup, mock_reply_message, mock_state): + """Test successful message handling""" + mock_db.get_user_by_message_id.return_value = 99999 + + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + # Mock the send_reply_to_user method + handlers.admin_reply_service.send_reply_to_user = AsyncMock() + + await handlers.handle_message(mock_reply_message, mock_state) + + # Verify database call + mock_db.get_user_by_message_id.assert_called_once_with(222) + + # Verify service call + handlers.admin_reply_service.send_reply_to_user.assert_called_once_with( + 99999, mock_reply_message, "test reply message", mock_keyboard_markup + ) + + # Verify state was set + mock_state.set_state.assert_called_once_with(FSM_STATES["CHAT"]) + + async def test_handle_message_no_reply(self, mock_db, mock_keyboard_markup, mock_message, mock_state): + """Test message handling without reply""" + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + # Mock the send_reply_to_user method to prevent it from being called + handlers.admin_reply_service.send_reply_to_user = AsyncMock() + + # Ensure reply_to_message is None + mock_message.reply_to_message = None + + await handlers.handle_message(mock_message, mock_state) + + # Verify error message was sent + mock_message.answer.assert_called_once_with(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"]) + + # Verify no database calls + mock_db.get_user_by_message_id.assert_not_called() + + # Verify send_reply_to_user was not called + handlers.admin_reply_service.send_reply_to_user.assert_not_called() + + # Verify state was not set + mock_state.set_state.assert_not_called() + + async def test_handle_message_user_not_found(self, mock_db, mock_keyboard_markup, mock_reply_message, mock_state): + """Test message handling when user is not found""" + mock_db.get_user_by_message_id.return_value = None + + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + await handlers.handle_message(mock_reply_message, mock_state) + + # Verify error message was sent + mock_reply_message.answer.assert_called_once_with(ERROR_MESSAGES["USER_NOT_FOUND"]) + + # Verify database call + mock_db.get_user_by_message_id.assert_called_once_with(222) + + # Verify state was not set + mock_state.set_state.assert_not_called() + + +class TestAdminReplyService: + """Test class for AdminReplyService""" + + @pytest.fixture + def mock_db(self): + """Mock database""" + db = Mock() + db.get_user_by_message_id = Mock() + return db + + @pytest.fixture + def service(self, mock_db): + """Create service instance""" + return AdminReplyService(mock_db) + + def test_get_user_id_for_reply_success(self, service, mock_db): + """Test successful user ID retrieval""" + mock_db.get_user_by_message_id.return_value = 12345 + + result = service.get_user_id_for_reply(111) + + assert result == 12345 + mock_db.get_user_by_message_id.assert_called_once_with(111) + + def test_get_user_id_for_reply_not_found(self, service, mock_db): + """Test user ID retrieval when user not found""" + mock_db.get_user_by_message_id.return_value = None + + with pytest.raises(UserNotFoundError, match="User not found for message_id: 111"): + service.get_user_id_for_reply(111) + + mock_db.get_user_by_message_id.assert_called_once_with(111) + + async def test_send_reply_to_user(self, service, mock_db): + """Test sending reply to user""" + message = Mock() + message.reply_to_message = Mock() + message.reply_to_message.message_id = 222 + markup = Mock() + + # Mock the send_text_message function + with pytest.MonkeyPatch().context() as m: + mock_send_text = AsyncMock() + m.setattr('helper_bot.handlers.group.services.send_text_message', mock_send_text) + + await service.send_reply_to_user(12345, message, "test reply", markup) + + mock_send_text.assert_called_once_with(12345, message, "test reply", markup) diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py new file mode 100644 index 0000000..37cecdd --- /dev/null +++ b/tests/test_refactored_private_handlers.py @@ -0,0 +1,180 @@ +"""Tests for refactored private handlers""" + +import pytest +from unittest.mock import Mock, AsyncMock, MagicMock +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.private.private_handlers import ( + create_private_handlers, PrivateHandlers +) +from helper_bot.handlers.private.services import BotSettings +from helper_bot.handlers.private.constants import FSM_STATES, BUTTON_TEXTS + + +class TestPrivateHandlers: + """Test class for PrivateHandlers""" + + @pytest.fixture + def mock_db(self): + """Mock database""" + db = Mock() + db.user_exists.return_value = False + db.add_new_user_in_db = Mock() + db.update_date_for_user = Mock() + db.update_info_about_stickers = Mock() + db.add_post_in_db = Mock() + db.add_new_message_in_db = Mock() + db.update_helper_message_in_db = Mock() + return db + + @pytest.fixture + def mock_settings(self): + """Mock bot settings""" + return BotSettings( + group_for_posts="test_posts", + group_for_message="test_message", + main_public="test_public", + group_for_logs="test_logs", + important_logs="test_important", + preview_link="test_link", + logs="test_logs_setting", + test="test_test_setting" + ) + + @pytest.fixture + def mock_message(self): + """Mock Telegram message""" + message = Mock(spec=types.Message) + # Создаем мок для from_user + from_user = Mock() + from_user.id = 12345 + from_user.full_name = "Test User" + from_user.username = "testuser" + from_user.is_bot = False + from_user.language_code = "ru" + message.from_user = from_user + + message.text = "test message" + + # Создаем мок для chat + chat = Mock() + chat.id = 12345 + message.chat = chat + + message.bot = Mock() + message.bot.send_message = AsyncMock() + message.forward = AsyncMock() + message.answer = AsyncMock() + message.answer_sticker = AsyncMock() + return message + + @pytest.fixture + def mock_state(self): + """Mock FSM state""" + state = Mock(spec=FSMContext) + state.set_state = AsyncMock() + state.get_state = AsyncMock(return_value=FSM_STATES["START"]) + return state + + def test_create_private_handlers(self, mock_db, mock_settings): + """Test creating private handlers instance""" + handlers = create_private_handlers(mock_db, mock_settings) + assert isinstance(handlers, PrivateHandlers) + assert handlers.db == mock_db + assert handlers.settings == mock_settings + + def test_private_handlers_initialization(self, mock_db, mock_settings): + """Test PrivateHandlers initialization""" + handlers = PrivateHandlers(mock_db, mock_settings) + assert handlers.db == mock_db + assert handlers.settings == mock_settings + assert handlers.user_service is not None + assert handlers.post_service is not None + assert handlers.sticker_service is not None + assert handlers.router is not None + + @pytest.mark.asyncio + async def test_handle_emoji_message(self, mock_db, mock_settings, mock_message, mock_state): + """Test emoji message handler""" + handlers = create_private_handlers(mock_db, mock_settings) + + # Mock the check_user_emoji function + with pytest.MonkeyPatch().context() as m: + m.setattr('helper_bot.handlers.private.private_handlers.check_user_emoji', lambda x: "😊") + + # Test the handler + await handlers.handle_emoji_message(mock_message, mock_state) + + # Verify state was set + mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) + + # Verify message was logged + mock_message.forward.assert_called_once_with(chat_id=mock_settings.group_for_logs) + + @pytest.mark.asyncio + async def test_handle_start_message(self, mock_db, mock_settings, mock_message, mock_state): + """Test start message handler""" + handlers = create_private_handlers(mock_db, mock_settings) + + # Mock the get_first_name and messages functions + with pytest.MonkeyPatch().context() as m: + m.setattr('helper_bot.handlers.private.private_handlers.get_first_name', lambda x: "Test") + m.setattr('helper_bot.handlers.private.private_handlers.messages.get_message', lambda x, y: "Hello Test!") + m.setattr('helper_bot.handlers.private.private_handlers.get_reply_keyboard', lambda x, y: Mock()) + + # Test the handler + await handlers.handle_start_message(mock_message, mock_state) + + # Verify state was set + mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) + + # Verify user was ensured to exist + mock_db.add_new_user_in_db.assert_called_once() + mock_db.update_date_for_user.assert_called_once() + + +class TestBotSettings: + """Test class for BotSettings dataclass""" + + def test_bot_settings_creation(self): + """Test creating BotSettings instance""" + settings = BotSettings( + group_for_posts="posts", + group_for_message="message", + main_public="public", + group_for_logs="logs", + important_logs="important", + preview_link="link", + logs="logs_setting", + test="test_setting" + ) + + assert settings.group_for_posts == "posts" + assert settings.group_for_message == "message" + assert settings.main_public == "public" + assert settings.group_for_logs == "logs" + assert settings.important_logs == "important" + assert settings.preview_link == "link" + assert settings.logs == "logs_setting" + assert settings.test == "test_setting" + + +class TestConstants: + """Test class for constants""" + + def test_fsm_states(self): + """Test FSM states constants""" + assert FSM_STATES["START"] == "START" + assert FSM_STATES["SUGGEST"] == "SUGGEST" + assert FSM_STATES["PRE_CHAT"] == "PRE_CHAT" + assert FSM_STATES["CHAT"] == "CHAT" + + def test_button_texts(self): + """Test button text constants""" + assert BUTTON_TEXTS["SUGGEST_POST"] == "📢Предложить свой пост" + assert BUTTON_TEXTS["SAY_GOODBYE"] == "👋🏼Сказать пока!" + assert BUTTON_TEXTS["LEAVE_CHAT"] == "Выйти из чата" + assert BUTTON_TEXTS["RETURN_TO_BOT"] == "Вернуться в бота" + assert BUTTON_TEXTS["WANT_STICKERS"] == "🤪Хочу стикеры" + assert BUTTON_TEXTS["CONNECT_ADMIN"] == "📩Связаться с админами" diff --git a/tests/test_utils.py b/tests/test_utils.py index 8381363..9c0c9d5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,16 +1,38 @@ import pytest -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, AsyncMock from datetime import datetime +import os from helper_bot.utils.helper_func import ( get_first_name, get_text_message, - check_username_and_full_name + check_username_and_full_name, + safe_html_escape, + download_file, + prepare_media_group_from_middlewares, + add_in_db_media_mediagroup, + add_in_db_media, + send_media_group_message_to_private_chat, + send_media_group_to_channel, + send_text_message, + send_photo_message, + send_video_message, + send_video_note_message, + send_audio_message, + send_voice_message, + check_access, + add_days_to_date, + get_banned_users_list, + get_banned_users_buttons, + delete_user_blacklist, + update_user_info, + check_user_emoji, + get_random_emoji ) from helper_bot.utils.messages import get_message from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance from database.db import BotDB - +import helper_bot.utils.messages as messages # Import for patching constants class TestHelperFunctions: """Тесты для вспомогательных функций""" @@ -83,6 +105,40 @@ class TestHelperFunctions: assert result is True +class TestSafeHtmlEscape: + """Тесты для функции безопасного экранирования HTML""" + + def test_safe_html_escape_normal_text(self): + """Тест экранирования обычного текста""" + result = safe_html_escape("Hello World") + assert result == "Hello World" + + def test_safe_html_escape_html_tags(self): + """Тест экранирования HTML тегов""" + result = safe_html_escape("") + assert result == "<script>alert('xss')</script>" + + def test_safe_html_escape_special_chars(self): + """Тест экранирования специальных символов""" + result = safe_html_escape("& < > \" '") + assert result == "& < > " '" + + def test_safe_html_escape_none_input(self): + """Тест экранирования None значения""" + result = safe_html_escape(None) + assert result == "" + + def test_safe_html_escape_empty_string(self): + """Тест экранирования пустой строки""" + result = safe_html_escape("") + assert result == "" + + def test_safe_html_escape_non_string_input(self): + """Тест экранирования нестрокового ввода""" + result = safe_html_escape(123) + assert result == "123" + + class TestMessages: """Тесты для системы сообщений""" @@ -114,20 +170,22 @@ class TestMessages: def test_get_message_all_types(self): """Тест всех типов сообщений""" - message_types = [ - "HELLO_MESSAGE", - "SUGGEST_NEWS", - "SUGGEST_NEWS_2", - "BYE_MESSAGE", - "SUCCESS_SEND_MESSAGE", - "CONNECT_WITH_ADMIN", - "QUESTION" - ] - - for msg_type in message_types: - result = get_message("Test", msg_type) - assert isinstance(result, str) - assert len(result) > 0 + # Patch the constants dictionary to include 'SUGGEST_NEWS_2' for testing purposes + with patch.dict(messages.constants, {'SUGGEST_NEWS_2': 'Test message 2'}): + message_types = [ + "HELLO_MESSAGE", + "SUGGEST_NEWS", + "SUGGEST_NEWS_2", + "BYE_MESSAGE", + "SUCCESS_SEND_MESSAGE", + "CONNECT_WITH_ADMIN", + "QUESTION" + ] + + for msg_type in message_types: + result = get_message("Test", msg_type) + assert isinstance(result, str) + assert len(result) > 0 class TestBaseDependencyFactory: @@ -149,25 +207,27 @@ class TestBaseDependencyFactory: def test_factory_initialization_with_mock_config(self): """Тест инициализации фабрики с мок конфигурацией""" - # Этот тест пропускаем, так как сложно замокать ConfigParser - # в контексте уже загруженных модулей - pass + # With os.getenv mocked in tests/mocks.py, BaseDependencyFactory can be directly tested + factory = BaseDependencyFactory() + assert factory.settings is not None + assert factory.database is not None def test_get_settings_method(self): """Тест метода get_settings""" - # Этот тест пропускаем, так как сложно замокать ConfigParser - # в контексте уже загруженных модулей - pass + # With os.getenv mocked, settings can be directly accessed and verified + factory = BaseDependencyFactory() + settings = factory.get_settings() + assert settings['Telegram']['bot_token'] == 'test_token_123' + assert settings['Settings']['logs'] is True def test_get_db_method(self): """Тест метода get_db""" - with patch('helper_bot.utils.base_dependency_factory.configparser.ConfigParser'): - with patch('helper_bot.utils.base_dependency_factory.BotDB') as mock_db: - factory = BaseDependencyFactory() - db = factory.get_db() - - assert db is not None - assert db == factory.database + # No need for configparser patch, os.getenv is already mocked globally + factory = BaseDependencyFactory() + db = factory.get_db() + + assert db is not None + assert db == factory.database class TestDatabaseIntegration: @@ -175,17 +235,18 @@ class TestDatabaseIntegration: def test_database_connection(self): """Тест подключения к базе данных""" - with patch('helper_bot.utils.base_dependency_factory.configparser.ConfigParser'): - with patch('helper_bot.utils.base_dependency_factory.BotDB') as mock_db: - factory = BaseDependencyFactory() - - # Проверяем, что база данных была создана - mock_db.assert_called_once() - - # Проверяем, что get_db возвращает тот же экземпляр - db1 = factory.get_db() - db2 = factory.get_db() - assert db1 is db2 + # No need for configparser patch, os.getenv is already mocked globally + factory = BaseDependencyFactory() + + # Проверяем, что база данных была создана + # (mock_db is already a Mock object from tests/mocks.py) + # So, we just check if it's the correct mock instance + assert factory.database is not None + + # Проверяем, что get_db возвращает тот же экземпляр + db1 = factory.get_db() + db2 = factory.get_db() + assert db1 is db2 class TestConfigurationHandling: @@ -193,16 +254,437 @@ class TestConfigurationHandling: def test_boolean_config_values(self): """Тест обработки булевых значений в конфигурации""" - # Этот тест пропускаем, так как сложно замокать ConfigParser - # в контексте уже загруженных модулей - pass + # Now that os.getenv is mocked, we can directly test + factory = BaseDependencyFactory() + settings = factory.get_settings() + assert settings['Settings']['logs'] is True + assert settings['Settings']['test'] is False def test_string_config_values(self): """Тест обработки строковых значений в конфигурации""" - # Этот тест пропускаем, так как сложно замокать ConfigParser - # в контексте уже загруженных модулей - pass + # Now that os.getenv is mocked, we can directly test + factory = BaseDependencyFactory() + settings = factory.get_settings() + assert settings['Telegram']['bot_token'] == 'test_token_123' + assert settings['Telegram']['main_public'] == '@test' + + +class TestDownloadFile: + """Тесты для функции скачивания файлов""" + + @pytest.mark.asyncio + async def test_download_file_success(self): + """Тест успешного скачивания файла""" + mock_message = Mock() + mock_message.bot = AsyncMock() + + # Мокаем get_file + mock_file = Mock() + mock_file.file_path = "photos/file_123.jpg" + mock_message.bot.get_file.return_value = mock_file + + # Мокаем download_file + mock_message.bot.download_file = AsyncMock() + + # Мокаем os.makedirs + with patch('os.makedirs') as mock_makedirs: + with patch('os.path.join', return_value="files/photos/file_123.jpg"): + result = await download_file(mock_message, "file_id_123") + + assert result == "files/photos/file_123.jpg" + mock_makedirs.assert_called() + mock_message.bot.get_file.assert_called_once_with("file_id_123") + mock_message.bot.download_file.assert_called_once() + + @pytest.mark.asyncio + async def test_download_file_exception(self): + """Тест обработки ошибки при скачивании""" + mock_message = Mock() + mock_message.bot = AsyncMock() + mock_message.bot.get_file.side_effect = Exception("Network error") + + with patch('os.makedirs'): + with patch('helper_bot.utils.helper_func.logger') as mock_logger: + result = await download_file(mock_message, "file_id_123") + + assert result is None + mock_logger.error.assert_called_once() + + +class TestPrepareMediaGroup: + """Тесты для подготовки медиагрупп""" + + @pytest.mark.asyncio + async def test_prepare_media_group_photos(self): + """Тест подготовки медиагруппы с фотографиями""" + album = [] + for i in range(3): + message = Mock() + message.photo = [Mock()] + message.photo[-1].file_id = f"photo_{i}" + album.append(message) + + result = await prepare_media_group_from_middlewares(album, "Тестовая подпись") + + assert len(result) == 3 + assert result[0].media == "photo_0" + assert result[1].media == "photo_1" + assert result[2].media == "photo_2" + assert result[2].caption == "Тестовая подпись" + + @pytest.mark.asyncio + async def test_prepare_media_group_mixed_types(self): + """Тест подготовки медиагруппы с разными типами медиа""" + album = [] + + # Фото + photo_message = Mock() + photo_message.photo = [Mock()] + photo_message.photo[-1].file_id = "photo_1" + album.append(photo_message) + + # Видео + video_message = Mock() + video_message.photo = None + video_message.video = Mock() + video_message.video.file_id = "video_1" + album.append(video_message) + + # Аудио + audio_message = Mock() + audio_message.photo = None + audio_message.video = None + audio_message.audio = Mock() + audio_message.audio.file_id = "audio_1" + album.append(audio_message) + + result = await prepare_media_group_from_middlewares(album, "Смешанная группа") + + assert len(result) == 3 + assert result[0].media == "photo_1" + assert result[1].media == "video_1" + assert result[2].media == "audio_1" + assert result[2].caption == "Смешанная группа" + + @pytest.mark.asyncio + async def test_prepare_media_group_empty_album(self): + """Тест подготовки пустой медиагруппы""" + album = [] + result = await prepare_media_group_from_middlewares(album, "Пустая группа") + assert result == [] + + @pytest.mark.asyncio + async def test_prepare_media_group_unsupported_type(self): + """Тест подготовки медиагруппы с неподдерживаемым типом""" + album = [] + message = Mock() + message.photo = None + message.video = None + message.audio = None + album.append(message) + + result = await prepare_media_group_from_middlewares(album, "Тест") + assert result == [] + + +class TestMediaDatabaseOperations: + """Тесты для операций с медиа в базе данных""" + + @pytest.mark.asyncio + async def test_add_in_db_media_mediagroup(self): + """Тест добавления медиагруппы в базу данных""" + sent_message = [] + for i in range(2): + message = Mock() + message.message_id = i + 1 + message.photo = [Mock()] + message.photo[-1].file_id = f"photo_{i}" + sent_message.append(message) + + mock_db = Mock() + + with patch('helper_bot.utils.helper_func.download_file', return_value=f"files/photo_{i}.jpg"): + await add_in_db_media_mediagroup(sent_message, mock_db) + + assert mock_db.add_post_content_in_db.call_count == 2 + + @pytest.mark.asyncio + async def test_add_in_db_media_photo(self): + """Тест добавления фото в базу данных""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = [Mock()] + mock_message.photo[-1].file_id = "photo_123" + + mock_db = Mock() + + with patch('helper_bot.utils.helper_func.download_file', return_value="files/photo_123.jpg"): + await add_in_db_media(mock_message, mock_db) + + mock_db.add_post_content_in_db.assert_called_once_with( + 123, 123, "files/photo_123.jpg", 'photo' + ) + + @pytest.mark.asyncio + async def test_add_in_db_media_video(self): + """Тест добавления видео в базу данных""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = None # У видео нет фото + mock_message.video = Mock() + mock_message.video.file_id = "video_123" + + mock_db = Mock() + + with patch('helper_bot.utils.helper_func.download_file', return_value="files/video_123.mp4"): + await add_in_db_media(mock_message, mock_db) + + mock_db.add_post_content_in_db.assert_called_once_with( + 123, 123, "files/video_123.mp4", 'video' + ) + + @pytest.mark.asyncio + async def test_add_in_db_media_voice(self): + """Тест добавления голосового сообщения в базу данных""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = None # У голосового сообщения нет фото + mock_message.video = None # У голосового сообщения нет видео + mock_message.voice = Mock() + mock_message.voice.file_id = "voice_123" + + mock_db = Mock() + + with patch('helper_bot.utils.helper_func.download_file', return_value="files/voice_123.ogg"): + await add_in_db_media(mock_message, mock_db) + + mock_db.add_post_content_in_db.assert_called_once_with( + 123, 123, "files/voice_123.ogg", 'voice' + ) + + +class TestSendMessageFunctions: + """Тесты для функций отправки сообщений""" + + @pytest.mark.asyncio + async def test_send_text_message_without_markup(self): + """Тест отправки текстового сообщения без разметки""" + mock_message = Mock() + mock_message.bot = AsyncMock() + mock_message.bot.send_message = AsyncMock() + + mock_sent_message = Mock() + mock_sent_message.message_id = 456 + mock_message.bot.send_message.return_value = mock_sent_message + + result = await send_text_message(123, mock_message, "Тестовое сообщение") + + assert result == 456 + mock_message.bot.send_message.assert_called_once_with( + chat_id=123, + text="Тестовое сообщение" + ) + + @pytest.mark.asyncio + async def test_send_text_message_with_markup(self): + """Тест отправки текстового сообщения с разметкой""" + mock_message = Mock() + mock_message.bot = AsyncMock() + mock_message.bot.send_message = AsyncMock() + + mock_markup = Mock() + mock_sent_message = Mock() + mock_sent_message.message_id = 456 + mock_message.bot.send_message.return_value = mock_sent_message + + result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup) + + assert result == 456 + mock_message.bot.send_message.assert_called_once_with( + chat_id=123, + text="Тестовое сообщение", + reply_markup=mock_markup + ) + + @pytest.mark.asyncio + async def test_send_photo_message(self): + """Тест отправки фото""" + mock_message = Mock() + mock_message.bot = AsyncMock() + mock_message.bot.send_photo = AsyncMock() + + mock_sent_message = Mock() + mock_message.bot.send_photo.return_value = mock_sent_message + + result = await send_photo_message(123, mock_message, "photo.jpg", "Подпись к фото") + + assert result == mock_sent_message + mock_message.bot.send_photo.assert_called_once_with( + chat_id=123, + caption="Подпись к фото", + photo="photo.jpg" + ) + + @pytest.mark.asyncio + async def test_send_video_message(self): + """Тест отправки видео""" + mock_message = Mock() + mock_message.bot = AsyncMock() + mock_message.bot.send_video = AsyncMock() + + mock_sent_message = Mock() + mock_message.bot.send_video.return_value = mock_sent_message + + result = await send_video_message(123, mock_message, "video.mp4", "Подпись к видео") + + assert result == mock_sent_message + mock_message.bot.send_video.assert_called_once_with( + chat_id=123, + caption="Подпись к видео", + video="video.mp4" + ) + + +class TestUtilityFunctions: + """Тесты для утилитарных функций""" + + def test_check_access(self): + """Тест проверки доступа""" + mock_db = Mock() + mock_db.is_admin.return_value = True + + result = check_access(123, mock_db) + assert result is True + + mock_db.is_admin.return_value = False + result = check_access(123, mock_db) + assert result is False + + def test_add_days_to_date(self): + """Тест добавления дней к дате""" + with patch('helper_bot.utils.helper_func.datetime') as mock_datetime: + from datetime import timedelta + mock_now = datetime(2024, 1, 1) + mock_datetime.now.return_value = mock_now + mock_datetime.timedelta = timedelta + + result = add_days_to_date(5) + expected_date = (mock_now + timedelta(days=5)).strftime("%d-%m-%Y") + assert result == expected_date + + def test_get_banned_users_list(self): + """Тест получения списка заблокированных пользователей""" + mock_db = Mock() + mock_db.get_banned_users_from_db_with_limits.return_value = [ + ("User1", 123, "Spam", "01-01-2025"), + ("User2", 456, "Violation", "02-01-2025") + ] + + result = get_banned_users_list(0, mock_db) + + assert "Список заблокированных пользователей:" in result + assert "User1" in result + assert "User2" in result + assert "Spam" in result + assert "Violation" in result + + def test_get_banned_users_buttons(self): + """Тест получения кнопок заблокированных пользователей""" + mock_db = Mock() + mock_db.get_banned_users_from_db.return_value = [ + ("User1", 123), + ("User2", 456) + ] + + result = get_banned_users_buttons(mock_db) + + assert len(result) == 2 + assert result[0] == ("User1", 123) + assert result[1] == ("User2", 456) + + def test_delete_user_blacklist(self): + """Тест удаления пользователя из черного списка""" + mock_db = Mock() + mock_db.delete_user_blacklist.return_value = True + + result = delete_user_blacklist(123, mock_db) + assert result is True + + mock_db.delete_user_blacklist.assert_called_once_with(user_id=123) + + +class TestUserManagement: + """Тесты для управления пользователями""" + + @pytest.mark.asyncio + async def test_update_user_info_new_user(self): + """Тест обновления информации о новом пользователе""" + mock_message = Mock() + mock_message.from_user.id = 123 + mock_message.from_user.full_name = "Test User" + mock_message.from_user.username = "testuser" + mock_message.from_user.is_bot = False + mock_message.from_user.language_code = "ru" + mock_message.answer = AsyncMock() + mock_message.bot.send_message = AsyncMock() + + with patch('helper_bot.utils.helper_func.get_first_name', return_value="Test"): + with patch('helper_bot.utils.helper_func.get_random_emoji', return_value="😀"): + with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db: + mock_bot_db.user_exists.return_value = False + mock_bot_db.add_new_user_in_db = Mock() + mock_bot_db.update_date_for_user = Mock() + + await update_user_info("test", mock_message) + + mock_bot_db.add_new_user_in_db.assert_called_once() + mock_bot_db.update_date_for_user.assert_called_once() + + def test_check_user_emoji_existing(self): + """Тест проверки эмодзи пользователя (существующий)""" + mock_message = Mock() + mock_message.from_user.id = 123 + + with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db: + mock_bot_db.check_emoji_for_user.return_value = "😀" + + result = check_user_emoji(mock_message) + assert result == "😀" + + def test_check_user_emoji_new(self): + """Тест проверки эмодзи пользователя (новый)""" + mock_message = Mock() + mock_message.from_user.id = 123 + + with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db: + mock_bot_db.check_emoji_for_user.return_value = None + mock_bot_db.update_emoji_for_user = Mock() + + with patch('helper_bot.utils.helper_func.get_random_emoji', return_value="😀"): + result = check_user_emoji(mock_message) + assert result == "😀" + mock_bot_db.update_emoji_for_user.assert_called_once_with(user_id=123, emoji="😀") + + def test_get_random_emoji_success(self): + """Тест получения случайного эмодзи (успех)""" + with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db: + mock_bot_db.check_emoji.return_value = False + + with patch('helper_bot.utils.helper_func.random.choice', return_value="😀"): + result = get_random_emoji() + assert result == "😀" + + def test_get_random_emoji_fallback(self): + """Тест получения случайного эмодзи (fallback)""" + with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db: + mock_bot_db.check_emoji.return_value = True # Все эмодзи заняты + + with patch('helper_bot.utils.helper_func.random.choice', return_value="😀"): + with patch('helper_bot.utils.helper_func.logger') as mock_logger: + result = get_random_emoji() + assert result == "Эмоджи не определен" + mock_logger.error.assert_called_once() if __name__ == '__main__': - pytest.main([__file__, '-v']) + pytest.main([__file__, '-v']) \ No newline at end of file diff --git a/voice_bot_v2.py b/voice_bot_v2.py deleted file mode 100644 index d94f598..0000000 --- a/voice_bot_v2.py +++ /dev/null @@ -1,9 +0,0 @@ -import asyncio - -from helper_bot.utils.base_dependency_factory import get_global_instance -from voice_bot.main import start_bot - -bdf = get_global_instance() - -if __name__ == '__main__': - asyncio.run(start_bot(get_global_instance()))