38 Commits

Author SHA1 Message Date
a0a7a47c8d Refactor Docker configuration and improve database initialization
- Updated `.dockerignore` to streamline ignored files and directories, focusing on essential components.
- Removed obsolete `Dockerfile.bot` to simplify the build process.
- Enhanced `run_helper.py` with a new `init_db` function to initialize the SQLite database if it doesn't exist, improving setup reliability.
- Removed the `/status` endpoint from `server_prometheus.py` to clean up unused functionality and improve code clarity.
2025-09-16 18:43:05 +03:00
31e29cdec0 Update banned users handling with async support and improved date parsing
- Modified `change_page` function in `callback_handlers.py` to use async methods for retrieving banned users and their buttons.
- Enhanced `get_banned_users_list` in `helper_func.py` to handle string timestamps and various date formats, ensuring robust date parsing.
- Added a new test case in `test_utils.py` to validate the handling of string timestamps in the banned users list retrieval.
2025-09-08 23:19:19 +03:00
5f6882d348 Implement audio record management features in AsyncBotDB and AudioRepository
- Added methods to delete audio moderation records and retrieve all audio records in async_db.py.
- Enhanced AudioRepository with functionality to delete audio records by file name and retrieve all audio message records.
- Improved logging for audio record operations to enhance monitoring and debugging capabilities.
- Updated related handlers to ensure proper integration of new audio management features.
2025-09-05 01:31:50 +03:00
fc0517c011 Enhance bot functionality with new features and improvements
- Added a new `/status` endpoint in `server_prometheus.py` to provide process status information, including uptime and resource usage metrics.
- Implemented a PID manager in `run_helper.py` to track the bot's process, improving monitoring capabilities.
- Introduced a method to delete audio moderation records in `audio_repository.py`, enhancing database management.
- Updated voice message handling in callback handlers to ensure proper deletion of audio moderation records.
- Improved error handling and logging in various services, ensuring better tracking of media processing and file downloads.
- Refactored media handling functions to streamline operations and improve code readability.
- Enhanced metrics tracking for file downloads and media processing, providing better insights into bot performance.
2025-09-04 00:46:45 +03:00
ae7bd476bb Refactor metrics handling and remove scheduler
- Removed the metrics scheduler functionality from the bot, transitioning to real-time metrics updates via middleware.
- Enhanced logging for metrics operations across various handlers to improve monitoring and debugging capabilities.
- Integrated metrics tracking for user activities and database errors, providing better insights into bot performance.
- Cleaned up code by removing obsolete comments and unused imports, improving overall readability and maintainability.
2025-09-03 19:18:04 +03:00
650acd5bce Add cancel ban process handler in admin handlers
- Introduced a new handler for canceling the ban process in `admin_handlers.py`, allowing users to easily abort ongoing ban actions.
- Enhanced error handling and logging for the cancellation process to improve user experience and debugging.
- Removed obsolete comments and cleaned up the code for better readability in the admin handlers.
- Updated metrics middleware to streamline event processing and improve logging clarity, ensuring comprehensive metrics collection for all events.
2025-09-03 17:35:51 +03:00
fe06008930 Enhance metrics handling and logging in bot
- Integrated metrics scheduler start and stop functionality in `run_helper.py` for better resource management.
- Improved logging for metrics server operations in `server_prometheus.py`, ensuring clearer error reporting and status updates.
- Updated metrics middleware to collect comprehensive metrics for all event types, enhancing monitoring capabilities.
- Added active user metrics tracking in `admin_handlers.py` to provide insights on user engagement.
- Refactored command and callback handling in `metrics_middleware.py` to improve clarity and error handling.
2025-09-03 16:16:14 +03:00
c8c7d50cbb Refactor metrics handling and improve logging
- Removed the MetricsManager initialization from `run_helper.py` to avoid duplication, as metrics are now handled in `main.py`.
- Updated logging levels in `server_prometheus.py` and `metrics_middleware.py` to use debug instead of info for less critical messages.
- Added metrics configuration to `BaseDependencyFactory` for better management of metrics settings.
- Deleted the obsolete `metrics_exporter.py` file to streamline the codebase.
- Updated various tests to reflect changes in the metrics handling and ensure proper functionality.
2025-09-03 00:33:20 +03:00
6fcecff97c Refactor voice handler and update welcome message
- Reintroduced the cancel handler registration in the voice handler for private chats, ensuring users can cancel interactions with the bot.
- Updated the welcome message in the messages utility to reflect the new voice bot access method and added instructions for using the /restart command.
- Improved clarity in the welcome message by including a link to the bot's help command and contact information for user support.
2025-09-02 22:42:33 +03:00
1ab427a7ba Enhance admin handlers with improved logging and error handling
- Added detailed logging for user ban processing in `process_ban_target` and `process_ban_reason` functions, including user data and error messages.
- Improved error handling for user input validation and database interactions.
- Updated `return_to_admin_menu` function to log user return actions.
- Enhanced media group handling in `PostPublishService` with better error logging and author ID retrieval.
- Added new button options in voice handlers and updated keyboard layouts for improved user interaction.
- Refactored album middleware to better handle media group messages and added documentation for clarity.
2025-09-02 22:20:34 +03:00
1c6a37bc12 Enhance bot functionality and refactor database interactions
- Added `ca-certificates` installation to Dockerfile for improved network security.
- Updated health check command in Dockerfile to include better timeout handling.
- Refactored `run_helper.py` to implement proper signal handling and logging during shutdown.
- Transitioned database operations to an asynchronous model in `async_db.py`, improving performance and responsiveness.
- Updated database schema to support new foreign key relationships and optimized indexing for better query performance.
- Enhanced various bot handlers to utilize async database methods, improving overall efficiency and user experience.
- Removed obsolete database and fix scripts to streamline the project structure.
2025-09-02 18:22:02 +03:00
013892dcb7 Remove obsolete configuration management and test settings files
- Deleted the `config.py` file responsible for managing bot configuration via environment variables and `.env` files, as it is no longer needed.
- Removed the `test_settings.ini` file used for testing, which contained mock configuration data.
- Cleaned up the project structure by eliminating unused files to enhance maintainability.
2025-09-01 20:30:10 +03:00
3a7b0f6219 Add voice bot welcome tracking functionality
- Implemented methods to check and mark if a user has received a welcome message from the voice bot in both async and synchronous database classes.
- Updated database schema to include a new field for tracking welcome message status.
- Enhanced voice handler to utilize the new tracking methods, improving user interaction flow and engagement metrics.
2025-09-01 19:43:46 +03:00
2d40f4496e Update voice bot functionality and clean up project structure
- Added voice message handling capabilities, including saving and deleting audio messages via callback queries.
- Refactored audio record management in the database to remove unnecessary fields and streamline operations.
- Introduced new keyboard options for voice interactions in the bot.
- Updated `.gitignore` to include voice user files for better project organization.
- Removed obsolete voice bot handler files to simplify the codebase.
2025-09-01 19:17:05 +03:00
d128e54694 Refactor project structure and remove obsolete files
- Deleted the Makefile, `README_TESTING.md`, and several deployment scripts to streamline the project.
- Updated `.dockerignore` to exclude unnecessary development files.
- Adjusted database schema comments for clarity.
- Refactored metrics handling in middleware for improved command extraction and logging.
- Enhanced command mappings for buttons and callbacks in constants for better maintainability.
- Start refactor voice bot
2025-09-01 00:54:10 +03:00
2368af3d93 Merge branch 'dev-7' into dev-8 2025-08-31 23:38:57 +03:00
98d12be67d Initial commit for dev-8 branch with all current changes 2025-08-31 15:35:42 +03:00
5c2f9e501d Enhance user activity tracking in private handlers
- Added functionality to log user messages and update user activity in the suggest router, improving user engagement metrics.
2025-08-31 11:17:49 +03:00
5fa2468467 Update Dockerfile.bot to include SQLite installation and initialize database schema
- Added sqlite3 installation to Dockerfile for database support.
- Changed ownership commands to use fixed UID for non-root user.
- Initialized SQLite database with schema during the build process.
- Removed outdated migration scripts to streamline the project structure.
2025-08-31 11:06:32 +03:00
ANDREY KATYKHIN
378c287649 Merge pull request #9 from KerradKerridi/dev-6
Dev 6
2025-08-30 14:58:45 +03:00
ac2d17dfe2 Update database path in docker-compose.yml for consistency with project structure 2025-08-30 01:42:30 +03:00
67cfdece45 Update Dockerfile.bot to create non-root user with fixed UID for improved security 2025-08-30 01:28:42 +03:00
8f338196b7 Refactor Docker and configuration files for improved structure and functionality
- Updated `.dockerignore` to include additional development and temporary files, enhancing build efficiency.
- Modified `.gitignore` to remove unnecessary entries and streamline ignored files.
- Enhanced `docker-compose.yml` with health checks, resource limits, and improved environment variable handling for better service management.
- Refactored `Dockerfile.bot` to utilize a multi-stage build for optimized image size and security.
- Improved `Makefile` with new commands for deployment, migration, and backup, along with enhanced help documentation.
- Updated `requirements.txt` to include new dependencies for environment variable management.
- Refactored metrics handling in the bot to ensure proper initialization and collection.
2025-08-29 23:15:06 +03:00
f097d69dd4 Enhance Makefile and update metrics handling in bot
- Added new commands in the Makefile for restarting individual services: `restart-bot`, `restart-prometheus`, and `restart-grafana`.
- Updated Prometheus and Grafana dashboard expressions for better metrics aggregation.
- Removed the `main_with_metrics.py` file and integrated metrics handling directly into the main bot file.
- Refactored middleware to improve metrics tracking and error handling across message and callback processing.
- Optimized metrics recording with enhanced bucket configurations for better performance monitoring.
2025-08-29 18:23:17 +03:00
c68db87901 Refactor project structure and enhance Docker support
- Removed unnecessary `__init__.py` and `Dockerfile` to streamline project organization.
- Updated `.dockerignore` and `.gitignore` to improve exclusion patterns for build artifacts and environment files.
- Enhanced `Makefile` with new commands for managing Docker containers and added help documentation.
- Introduced `pyproject.toml` for better project metadata management and dependency tracking.
- Updated `requirements.txt` to reflect changes in dependencies for metrics and monitoring.
- Refactored various handler files to improve code organization and maintainability.
2025-08-29 16:49:28 +03:00
8cee629e28 Add middleware and refactor admin handlers for improved functionality
- Introduced `DependenciesMiddleware` and `BlacklistMiddleware` for enhanced request handling across all routers.
- Refactored admin handlers to utilize new middleware, improving access control and error handling.
- Updated the `admin_router` to include middleware for access checks and streamlined the process of banning users.
- Enhanced the structure of admin handler imports for better organization and maintainability.
- Improved error handling in various admin functions to ensure robust user interactions.
2025-08-28 23:54:17 +03:00
f75e7f82c9 Enhance private handlers structure and add database support
- Introduced a new `PrivateHandlers` class to encapsulate private message handling logic, improving organization and maintainability.
- Added new dependencies in `requirements.txt` for database support with `aiosqlite`.
- Updated the private handlers to utilize modular components for better separation of concerns and easier testing.
- Implemented error handling and logging for improved robustness in message processing.
2025-08-28 01:41:19 +03:00
e17a9f9c29 Remove pytest configuration file and update test files for async compatibility
- Deleted `pytest.ini` to streamline test configuration.
- Added `pytest_asyncio` plugin support in `conftest.py`.
- Marked `test_monitor` as an async test to ensure proper execution in an asynchronous context.
2025-08-27 22:37:17 +03:00
86b6903920 Add auto unban functionality and update related tests and dependencies 2025-08-27 20:56:22 +03:00
748670816f Refactor keyboard layout for improved organization and add admin keyboard tests
- Updated the keyboard layout in `get_reply_keyboard` and `get_reply_keyboard_admin` functions to use `row` for better organization of buttons.
- Added unit tests for the admin keyboard to verify button arrangement and functionality.
- Ensured that each button is placed in its own row for clarity in the user interface.
2025-08-27 20:20:53 +03:00
dc0e5d788c Implement OS detection and enhance disk monitoring in ServerMonitor
- Added OS detection functionality to the ServerMonitor class, allowing for tailored disk usage and uptime calculations based on the operating system (macOS or Ubuntu).
- Introduced methods for retrieving disk usage and I/O statistics specific to the detected OS.
- Updated the process status check to return uptime information for monitored processes.
- Enhanced the status message format to include disk space emojis and process uptime details.
- Updated tests to reflect changes in process status checks and output formatting.
2025-08-27 20:09:48 +03:00
0b2440e586 Add server monitoring functionality and update Makefile and requirements
- Introduced a new server monitoring module in `run_helper.py` with graceful shutdown handling.
- Updated `.gitignore` to include PID files.
- Added `test-monitor` target in `Makefile` for testing the server monitoring module.
- Included `psutil` in `requirements.txt` for system monitoring capabilities.
2025-08-27 01:17:15 +03:00
9688cdd85f Refactor admin handlers to improve access control and state management. Added checks for admin rights in ban functions and streamlined router inclusion order in main bot file. Updated keyboard layouts for better user experience and removed unused state definitions. 2025-08-27 00:28:32 +03:00
62af3b73c6 Enhance database connection handling in BotDB class with error checking for file existence and access permissions. Implement methods for database integrity checks and WAL file cleanup. Update database initialization to use absolute project path for improved reliability. 2025-08-26 19:33:11 +03:00
86773cfe20 fix 2025-08-26 19:20:25 +03:00
264818b0a6 Merge branch 'master' of https://github.com/KerradKerridi/telegram-helper-bot 2025-08-26 19:06:01 +03:00
706d91e739 Update requirements.txt to streamline dependencies, adding aiogram and pytest-asyncio while removing unused packages. Organized sections for core dependencies, logging, testing, and development tools. 2025-08-26 19:02:39 +03:00
ANDREY KATYKHIN
1a02f3c278 Merge pull request #8 from KerradKerridi/merge-voice-1
Merge voice 1
2025-08-26 18:57:53 +03:00
132 changed files with 17620 additions and 5851 deletions

View File

@@ -1,37 +1,29 @@
__pycache__/ # .dockerignore
*.py[cod] .*
!.gitignore
# Исключаем тяжелые папки
voice_users/
logs/
.venv/
__pycache__
*.pyc
*.pyo *.pyo
*.pyd *.pyd
*.so
*.egg-info/ # Исключаем файлы БД (они создаются при запуске)
.eggs/ database/*.db
database/*.db-*
# Служебные файлы
Dockerfile
docker-compose*
README.md
.env .env
.venv *.log
.vscode/
tests/
test/
docs/
.idea/ .idea/
.git/ .vscode/
.gitignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.pyd
# Local settings
settings_example.ini
# Databases and runtime files
*.db
*.db-shm
*.db-wal
logs/
# Tests and artifacts
.coverage
.pytest_cache/
htmlcov/
**/tests/
# Stickers and large assets (if not needed at runtime)
Stick/

55
.gitignore vendored
View File

@@ -1,13 +1,20 @@
# Database files
/database/tg-bot-database.db /database/tg-bot-database.db
/database/tg-bot-database.db-shm /database/tg-bot-database.db-shm
/database/tg-bot-database.db-wm
/database/tg-bot-database.db-wal /database/tg-bot-database.db-wal
/database/test.db /database/test.db
/database/test.db-shm /database/test.db-shm
/database/test.db-wal /database/test.db-wal
/settings.ini /database/test_auto_unban.db
/database/test_auto_unban.db-shm
/database/test_auto_unban.db-wal
/myenv/ /myenv/
/venv/ /venv/
/.idea/ /.venv/
# Logs
/logs/*.log /logs/*.log
# Testing and coverage files # Testing and coverage files
@@ -29,6 +36,7 @@ test.db
# IDE and editor files # IDE and editor files
.vscode/ .vscode/
.idea/
*.swp *.swp
*.swo *.swo
*~ *~
@@ -41,4 +49,47 @@ test.db
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
# Documentation files
PERFORMANCE_IMPROVEMENTS.md 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/
# Other files
voice_users/
files/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.9.6

View File

@@ -1,37 +1,54 @@
# syntax=docker/dockerfile:1 ###########################################
# Этап 1: Сборщик (Builder)
###########################################
FROM python:3.9-alpine as builder
# Use a lightweight Python image # Устанавливаем инструменты для компиляции + linux-headers для psutil
FROM python:3.11-slim RUN apk add --no-cache \
gcc \
g++ \
musl-dev \
python3-dev \
linux-headers # ← ЭТО КРИТИЧЕСКИ ВАЖНО ДЛЯ psutil
# Prevent Python from writing .pyc files and enable unbuffered logs WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \ COPY requirements.txt .
PYTHONUNBUFFERED=1
# Install system dependencies (if required by Python packages) # Устанавливаем зависимости
RUN apt-get update \ RUN pip install --no-cache-dir --target /install -r requirements.txt
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
###########################################
# Этап 2: Финальный образ (Runtime)
###########################################
FROM python:3.9-alpine as runtime
# Минимальные рантайм-зависимости
RUN apk add --no-cache \
libstdc++ \
sqlite-libs
# Создаем пользователя
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
# Set working directory
WORKDIR /app WORKDIR /app
# Create non-root user # Копируем зависимости
RUN useradd -m appuser \ COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages
&& chown -R appuser:appuser /app
# Install Python dependencies first for better layer caching # Создаем структуру папок
COPY requirements.txt ./ RUN mkdir -p database logs voice_users && \
RUN pip install --no-cache-dir -r requirements.txt chown -R 1001:1001 /app
# Copy project files # Копируем исходный код
COPY . . COPY --chown=1001:1001 . .
# Ensure runtime directories exist and are writable USER 1001
RUN mkdir -p logs database \
&& chown -R appuser:appuser /app
# Switch to non-root user # Healthcheck
USER appuser HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=5)" || exit 1
# Run the bot EXPOSE 8080
CMD ["python", "run_helper.py"]
CMD ["python", "-u", "run_helper.py"]

View File

@@ -1,73 +0,0 @@
.PHONY: help test test-db test-coverage test-html clean install
# 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"
# Install dependencies
install:
python3 -m pip install -r requirements.txt
python3 -m pip install pytest-cov
# Run all tests
test:
python3 -m pytest tests/ -v
# Run database tests only
test-db:
python3 -m pytest tests/test_db.py -v
# Run bot tests only
test-bot:
python3 -m pytest tests/test_bot.py -v
# Run media handler tests only
test-media:
python3 -m pytest tests/test_media_handlers.py -v
# Run error handling tests only
test-errors:
python3 -m pytest tests/test_error_handling.py -v
# Run utils tests only
test-utils:
python3 -m pytest tests/test_utils.py -v
# Run keyboard and filter tests only
test-keyboards:
python3 -m pytest tests/test_keyboards_and_filters.py -v
# Run tests with coverage
test-coverage:
python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=term
# 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"
# Show coverage report only
coverage:
python3 -m coverage report --include="helper_bot/*,database/*"
# 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

171
RATE_LIMITING_SOLUTION.md Normal file
View File

@@ -0,0 +1,171 @@
# Решение проблемы Flood Control в Telegram Bot
## Проблема
В логах бота наблюдались ошибки типа:
```
Flood control exceeded on method 'SendVoice' in chat 1322897572. Retry in 3 seconds.
```
Эти ошибки возникают при превышении лимитов Telegram Bot API:
- Не более 30 сообщений в секунду от одного бота глобально
- Не более 1 сообщения в секунду в один чат
- Дополнительные ограничения для разных типов сообщений
## Решение
Реализована комплексная система rate limiting, включающая:
### 1. Основные компоненты
#### `rate_limiter.py`
- **ChatRateLimiter**: Ограничивает скорость отправки сообщений для конкретного чата
- **GlobalRateLimiter**: Глобальные ограничения для всех чатов
- **RetryHandler**: Обработка повторных попыток с экспоненциальной задержкой
- **TelegramRateLimiter**: Основной класс, объединяющий все компоненты
#### `rate_limit_monitor.py`
- **RateLimitMonitor**: Мониторинг и статистика rate limiting
- Отслеживание успешных/неудачных запросов
- Анализ ошибок и производительности
- Статистика по чатам
#### `rate_limit_config.py`
- Конфигурации для разных окружений (development, production, strict)
- Адаптивные настройки на основе уровня ошибок
- Настройки для разных типов сообщений
#### `rate_limit_middleware.py`
- Middleware для автоматического применения rate limiting
- Перехват всех исходящих сообщений
- Прозрачная интеграция с существующим кодом
### 2. Ключевые особенности
#### Rate Limiting
- **Настраиваемая скорость**: 0.5 сообщений в секунду на чат (по умолчанию)
- **Burst protection**: Максимум 2 сообщения подряд
- **Глобальные ограничения**: 10 сообщений в секунду глобально
- **Адаптивные задержки**: Увеличение задержек при ошибках
#### Retry Mechanism
- **Экспоненциальная задержка**: Увеличение времени ожидания при повторных попытках
- **Максимальные ограничения**: Ограничение максимального времени ожидания
- **Умная обработка ошибок**: Разные стратегии для разных типов ошибок
#### Мониторинг
- **Детальная статистика**: Отслеживание всех запросов и ошибок
- **Анализ производительности**: Процент успеха, время ожидания, активность
- **Административные команды**: `/ratelimit_stats`, `/ratelimit_errors`, `/reset_ratelimit_stats`
### 3. Интеграция
#### Обновленные функции
```python
# helper_func.py
async def send_voice_message(chat_id, message, voice, markup=None):
from .rate_limiter import send_with_rate_limit
async def _send_voice():
if markup is None:
return await message.bot.send_voice(chat_id=chat_id, voice=voice)
else:
return await message.bot.send_voice(chat_id=chat_id, voice=voice, reply_markup=markup)
return await send_with_rate_limit(_send_voice, chat_id)
```
#### Middleware
```python
# voice_handler.py
from helper_bot.middlewares.rate_limit_middleware import MessageSendMiddleware
def _setup_middleware(self):
self.router.message.middleware(DependenciesMiddleware())
self.router.message.middleware(BlacklistMiddleware())
self.router.message.middleware(MessageSendMiddleware()) # Новый middleware
```
### 4. Конфигурация
#### Production настройки (по умолчанию)
```python
PRODUCTION_CONFIG = RateLimitSettings(
messages_per_second=0.5, # 1 сообщение каждые 2 секунды
burst_limit=2, # Максимум 2 сообщения подряд
retry_after_multiplier=1.5,
max_retry_delay=30.0,
max_retries=3,
voice_message_delay=2.5, # Дополнительная задержка для голосовых
media_message_delay=2.0,
text_message_delay=1.5
)
```
#### Адаптивная конфигурация
Система автоматически ужесточает ограничения при высоком уровне ошибок:
- При >10% ошибок: уменьшение скорости в 2 раза
- При <1% ошибок: увеличение скорости на 20%
### 5. Мониторинг и администрирование
#### Команды для администраторов
- `/ratelimit_stats` - Показать статистику rate limiting
- `/ratelimit_errors` - Показать недавние ошибки
- `/reset_ratelimit_stats` - Сбросить статистику
#### Пример вывода статистики
```
📊 Статистика Rate Limiting
🔢 Общая статистика:
Всего запросов: 1250
• Процент успеха: 98.4%
• Процент ошибок: 1.6%
• Запросов в минуту: 12.5
• Среднее время ожидания: 1.2с
• Активных чатов: 45
• Ошибок за час: 3
🔍 Детальная статистика:
• Успешных запросов: 1230
• Неудачных запросов: 20
• RetryAfter ошибок: 15
• Других ошибок: 5
```
### 6. Тестирование
Создан полный набор тестов в `test_rate_limiter.py`:
- Тесты всех компонентов
- Интеграционные тесты
- Тесты конфигурации
- Тесты мониторинга
Запуск тестов:
```bash
pytest tests/test_rate_limiter.py -v
```
### 7. Преимущества решения
1. **Предотвращение ошибок**: Автоматическое соблюдение лимитов API
2. **Прозрачность**: Минимальные изменения в существующем коде
3. **Мониторинг**: Полная видимость производительности
4. **Адаптивность**: Автоматическая настройка под нагрузку
5. **Надежность**: Умная обработка ошибок и повторных попыток
6. **Масштабируемость**: Поддержка множества чатов
### 8. Рекомендации по использованию
1. **Мониторинг**: Регулярно проверяйте статистику через `/ratelimit_stats`
2. **Настройка**: При необходимости корректируйте конфигурацию под ваши нужды
3. **Алерты**: Настройте уведомления при высоком проценте ошибок
4. **Тестирование**: Проверяйте работу в тестовой среде перед продакшеном
### 9. Будущие улучшения
- Интеграция с системой метрик (Prometheus/Grafana)
- Автоматическое масштабирование ограничений
- A/B тестирование разных конфигураций
- Интеграция с системой алертов

View File

@@ -1,259 +0,0 @@
# Тестирование Telegram Helper Bot
Этот документ описывает систему тестирования для Telegram Helper Bot.
## Структура тестов
Тесты организованы в следующие файлы:
- `tests/test_bot.py` - Основные тесты бота (запуск, хэндлеры, интеграция)
- `tests/test_media_handlers.py` - Тесты обработки медиа-контента
- `tests/test_error_handling.py` - Тесты обработки ошибок и граничных случаев
- `tests/test_utils.py` - Тесты утилит и вспомогательных функций
- `tests/test_keyboards_and_filters.py` - Тесты клавиатур и фильтров
- `tests/test_db.py` - Тесты базы данных
- `tests/conftest.py` - Общие фикстуры и конфигурация
## Установка зависимостей
```bash
make install
```
## Запуск тестов
### Все тесты
```bash
make test
```
### Отдельные категории тестов
```bash
# Тесты базы данных
make test-db
# Тесты бота (запуск и хэндлеры)
make test-bot
# Тесты обработки медиа
make test-media
# Тесты обработки ошибок
make test-errors
# Тесты утилит
make test-utils
# Тесты клавиатур и фильтров
make test-keyboards
```
### Тесты с покрытием
```bash
# Покрытие с выводом в терминал
make test-coverage
# Покрытие с HTML отчетом
make test-html
```
### Фильтрация тестов
```bash
# Только unit тесты
pytest -m unit
# Только интеграционные тесты
pytest -m integration
# Только асинхронные тесты
pytest -m asyncio
# Исключить медленные тесты
pytest -m "not slow"
# Конкретный файл тестов
pytest tests/test_bot.py
# Конкретный тест
pytest tests/test_bot.py::TestBotStartup::test_bot_initialization
```
## Типы тестов
### Unit тесты
Тестируют отдельные функции и компоненты в изоляции:
- Вспомогательные функции (`get_first_name`, `get_text_message`)
- Утилиты (`BaseDependencyFactory`, `get_message`)
- Фильтры (`ChatTypeFilter`)
- Клавиатуры
### Интеграционные тесты
Тестируют взаимодействие между компонентами:
- Регистрация роутеров в диспетчере
- Обработка сообщений через хэндлеры
- Интеграция с базой данных
### Асинхронные тесты
Тестируют асинхронные функции:
- Хэндлеры сообщений
- Запуск бота
- Обработка медиа-контента
## Моки и фикстуры
### Основные фикстуры
- `mock_message` - Мок сообщения Telegram
- `mock_state` - Мок состояния FSM
- `mock_db` - Мок базы данных
- `mock_bot` - Мок бота
- `mock_dispatcher` - Мок диспетчера
- `mock_factory` - Мок фабрики зависимостей
### Специализированные фикстуры
- `sample_photo_message` - Сообщение с фото
- `sample_video_message` - Сообщение с видео
- `sample_audio_message` - Сообщение с аудио
- `sample_voice_message` - Голосовое сообщение
- `sample_video_note_message` - Видеокружок
- `sample_media_group` - Медиагруппа
- `sample_text_message` - Текстовое сообщение
## Покрытие тестами
### Основные компоненты
- ✅ Запуск бота (`start_bot`)
- ✅ Приватные хэндлеры (`handle_start_message`, `suggest_post`, etc.)
- ✅ Обработка медиа-контента (фото, видео, аудио, голос)
- ✅ Обработка ошибок и исключений
- ✅ Утилиты и вспомогательные функции
- ✅ Клавиатуры и фильтры
- ✅ Фабрика зависимостей
### Тестируемые сценарии
- ✅ Новые пользователи
- ✅ Существующие пользователи
- ✅ Пользователи без username
- ✅ Обработка различных типов контента
- ✅ Медиагруппы
- ✅ Ошибки при получении стикеров
- ✅ Ошибки базы данных
- ✅ Граничные случаи (пустой текст, отсутствие подписей)
## Настройка окружения
### Переменные окружения
Для тестов не требуются реальные токены бота или подключения к базе данных, так как все внешние зависимости замоканы.
### Конфигурация pytest
Настройки pytest находятся в файле `pytest.ini`:
- Автоматический режим asyncio
- Фильтрация предупреждений
- Маркеры для категоризации тестов
## Добавление новых тестов
### Структура теста
```python
@pytest.mark.asyncio
async def test_function_name(mock_message, mock_state, mock_db):
"""Описание теста"""
# Arrange (подготовка)
mock_message.text = "test"
# Act (действие)
result = await function_to_test(mock_message, mock_state)
# Assert (проверка)
assert result is True
mock_message.answer.assert_called_once()
```
### Маркировка тестов
```python
@pytest.mark.unit # Unit тест
@pytest.mark.integration # Интеграционный тест
@pytest.mark.asyncio # Асинхронный тест
@pytest.mark.slow # Медленный тест
```
### Использование фикстур
```python
def test_with_fixtures(mock_message, sample_photo_message, mock_db):
# Используем готовые фикстуры
pass
```
## Отладка тестов
### Подробный вывод
```bash
pytest -v -s
```
### Остановка на первой ошибке
```bash
pytest -x
```
### Вывод полного traceback
```bash
pytest --tb=long
```
### Запуск конкретного теста
```bash
pytest tests/test_bot.py::TestPrivateHandlers::test_handle_start_message_new_user -v
```
## CI/CD интеграция
Тесты могут быть интегрированы в CI/CD pipeline:
```yaml
# Пример для GitHub Actions
- name: Run tests
run: |
make install
make test-coverage
```
## Покрытие кода
Для просмотра покрытия кода:
```bash
make test-html
# Открыть htmlcov/index.html в браузере
```
## Лучшие практики
1. **Изоляция тестов** - каждый тест должен быть независимым
2. **Использование моков** - избегайте реальных внешних зависимостей
3. **Описательные имена** - имена тестов должны описывать что тестируется
4. **Arrange-Act-Assert** - структурируйте тесты по этому паттерну
5. **Фикстуры** - используйте фикстуры для переиспользования кода
6. **Маркировка** - правильно маркируйте тесты для фильтрации
## Устранение неполадок
### Ошибки импорта
Убедитесь, что Python path настроен правильно:
```bash
export PYTHONPATH="${PYTHONPATH}:$(pwd)"
```
### Ошибки asyncio
Для асинхронных тестов используйте маркер `@pytest.mark.asyncio`
### Ошибки моков
Проверьте, что все внешние зависимости замоканы:
```python
with patch('module.function') as mock_func:
# тест
```
### Медленные тесты
Используйте маркер `@pytest.mark.slow` для медленных тестов и исключайте их при необходимости:
```bash
pytest -m "not slow"
```

26
database/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""
Пакет для работы с базой данных.
Содержит:
- models: модели данных
- base: базовый класс для работы с БД
- repositories: репозитории для разных сущностей
- repository_factory: фабрика репозиториев
- async_db: основной класс AsyncBotDB
"""
from .models import (
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
MessageContentLink, Admin, Migration, AudioMessage, AudioListenRecord, AudioModerate
)
from .repository_factory import RepositoryFactory
from .base import DatabaseConnection
from .async_db import AsyncBotDB
# Для обратной совместимости экспортируем старый интерфейс
__all__ = [
'User', 'BlacklistUser', 'UserMessage', 'TelegramPost', 'PostContent',
'MessageContentLink', 'Admin', 'Migration', 'AudioMessage', 'AudioListenRecord', 'AudioModerate',
'RepositoryFactory', 'DatabaseConnection', 'AsyncBotDB'
]

368
database/async_db.py Normal file
View File

@@ -0,0 +1,368 @@
import aiosqlite
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from database.repository_factory import RepositoryFactory
from database.models import (
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
Admin, AudioMessage
)
class AsyncBotDB:
"""Новый асинхронный класс для работы с базой данных с использованием репозиториев."""
def __init__(self, db_path: str):
self.factory = RepositoryFactory(db_path)
self.logger = self.factory.users.logger
async def create_tables(self):
"""Создание всех таблиц в базе данных."""
await self.factory.create_all_tables()
self.logger.info("Все таблицы успешно созданы")
# Методы для работы с пользователями
async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли пользователь в базе данных."""
return await self.factory.users.user_exists(user_id)
async def add_user(self, user: User):
"""Добавление нового пользователя."""
await self.factory.users.add_user(user)
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Получение информации о пользователе."""
user = await self.factory.users.get_user_info(user_id)
if user:
return {
'username': user.username,
'full_name': user.full_name,
'has_stickers': user.has_stickers,
'emoji': user.emoji
}
return None
async def get_username(self, user_id: int) -> Optional[str]:
"""Возвращает username пользователя."""
return await self.factory.users.get_username(user_id)
async def get_user_id_by_username(self, username: str) -> Optional[int]:
"""Возвращает user_id пользователя по username."""
return await self.factory.users.get_user_id_by_username(username)
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
"""Возвращает full_name пользователя."""
return await self.factory.users.get_full_name_by_id(user_id)
async def get_username_and_full_name(self, user_id: int) -> tuple[Optional[str], Optional[str]]:
"""Возвращает username и full_name пользователя."""
username = await self.get_username(user_id)
full_name = await self.get_full_name_by_id(user_id)
return username, full_name
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID."""
return await self.factory.users.get_user_by_id(user_id)
async def get_user_first_name(self, user_id: int) -> Optional[str]:
"""Возвращает first_name пользователя."""
return await self.factory.users.get_user_first_name(user_id)
async def get_all_user_id(self) -> List[int]:
"""Возвращает список всех user_id."""
return await self.factory.users.get_all_user_ids()
async def get_last_users(self, limit: int = 30) -> List[tuple]:
"""Получение последних пользователей."""
return await self.factory.users.get_last_users(limit)
async def update_user_date(self, user_id: int):
"""Обновление даты последнего изменения пользователя."""
await self.factory.users.update_user_date(user_id)
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None):
"""Обновление информации о пользователе."""
await self.factory.users.update_user_info(user_id, username, full_name)
async def update_user_emoji(self, user_id: int, emoji: str):
"""Обновление эмодзи пользователя."""
await self.factory.users.update_user_emoji(user_id, emoji)
async def update_stickers_info(self, user_id: int):
"""Обновление информации о стикерах."""
await self.factory.users.update_stickers_info(user_id)
async def get_stickers_info(self, user_id: int) -> bool:
"""Получение информации о стикерах."""
return await self.factory.users.get_stickers_info(user_id)
async def check_emoji_exists(self, emoji: str) -> bool:
"""Проверка существования эмодзи."""
return await self.factory.users.check_emoji_exists(emoji)
async def get_user_emoji(self, user_id: int) -> str:
"""Получает эмодзи пользователя."""
return await self.factory.users.get_user_emoji(user_id)
async def check_emoji_for_user(self, user_id: int) -> str:
"""Проверяет, есть ли уже у пользователя назначенный emoji."""
return await self.factory.users.check_emoji_for_user(user_id)
# Методы для работы с сообщениями
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None):
"""Добавление сообщения пользователя."""
if date is None:
from datetime import datetime
date = int(datetime.now().timestamp())
message = UserMessage(
message_text=message_text,
user_id=user_id,
telegram_message_id=message_id,
date=date
)
await self.factory.messages.add_message(message)
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
"""Получение пользователя по message_id."""
return await self.factory.messages.get_user_by_message_id(message_id)
# Методы для работы с постами
async def add_post(self, post: TelegramPost):
"""Добавление поста."""
await self.factory.posts.add_post(post)
async def update_helper_message(self, message_id: int, helper_message_id: int):
"""Обновление helper сообщения."""
await self.factory.posts.update_helper_message(message_id, helper_message_id)
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str):
"""Добавление контента поста."""
return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type)
async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id."""
return await self.factory.posts.get_post_content_by_helper_id(last_post_id)
async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id."""
return await self.factory.posts.get_post_text_by_helper_id(last_post_id)
async def get_post_ids_from_telegram_by_last_id(self, last_post_id: int) -> List[int]:
"""Получает ID сообщений по helper_text_message_id."""
return await self.factory.posts.get_post_ids_by_helper_id(last_post_id)
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id."""
return await self.factory.posts.get_author_id_by_message_id(message_id)
async def get_author_id_by_helper_message_id(self, helper_text_message_id: int) -> Optional[int]:
"""Получает ID автора по helper_text_message_id."""
return await self.factory.posts.get_author_id_by_helper_message_id(helper_text_message_id)
# Методы для работы с черным списком
async def set_user_blacklist(self, user_id: int, user_name: str = None,
message_for_user: str = None, date_to_unban: int = None):
"""Добавляет пользователя в черный список."""
blacklist_user = BlacklistUser(
user_id=user_id,
message_for_user=message_for_user,
date_to_unban=date_to_unban
)
await self.factory.blacklist.add_user(blacklist_user)
async def delete_user_blacklist(self, user_id: int) -> bool:
"""Удаляет пользователя из черного списка."""
return await self.factory.blacklist.remove_user(user_id)
async def check_user_in_blacklist(self, user_id: int) -> bool:
"""Проверяет, существует ли запись с данным user_id в blacklist."""
return await self.factory.blacklist.user_exists(user_id)
async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List[tuple]:
"""Получение пользователей из черного списка."""
users = await self.factory.blacklist.get_all_users(offset, limit)
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users]
async def get_banned_users_from_db(self) -> List[tuple]:
"""Возвращает список пользователей в черном списке."""
users = await self.factory.blacklist.get_all_users_no_limit()
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users]
async def get_banned_users_from_db_with_limits(self, offset: int, limit: int) -> List[tuple]:
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
users = await self.factory.blacklist.get_all_users(offset, limit)
return [(user.user_id, user.message_for_user, user.date_to_unban) for user in users]
async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]:
"""Возвращает информацию о пользователе в черном списке по user_id."""
user = await self.factory.blacklist.get_user(user_id)
if user:
return (user.user_id, user.message_for_user, user.date_to_unban)
return None
async def get_blacklist_count(self) -> int:
"""Получение количества пользователей в черном списке."""
return await self.factory.blacklist.get_count()
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]:
"""Возвращает список пользователей, у которых истек срок блокировки."""
return await self.factory.blacklist.get_users_for_unblock_today(current_timestamp)
# Методы для работы с администраторами
async def add_admin(self, user_id: int, role: str = "admin"):
"""Добавление администратора."""
admin = Admin(user_id=user_id, role=role)
await self.factory.admins.add_admin(admin)
async def remove_admin(self, user_id: int):
"""Удаление администратора."""
await self.factory.admins.remove_admin(user_id)
async def is_admin(self, user_id: int) -> bool:
"""Проверка, является ли пользователь администратором."""
return await self.factory.admins.is_admin(user_id)
async def get_all_admins(self) -> list[Admin]:
"""Получение всех администраторов."""
return await self.factory.admins.get_all_admins()
# Методы для работы с аудио
async def add_audio_record(self, file_name: str, author_id: int, date_added: str,
listen_count: int, file_id: str):
"""Добавляет информацию о войсе пользователя."""
audio = AudioMessage(
file_name=file_name,
author_id=author_id,
date_added=date_added,
listen_count=listen_count,
file_id=file_id
)
await self.factory.audio.add_audio_record(audio)
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None:
"""Добавляет простую запись об аудио файле."""
await self.factory.audio.add_audio_record_simple(file_name, user_id, date_added)
async def last_date_audio(self) -> Optional[str]:
"""Получает дату последнего войса."""
return await self.factory.audio.get_last_date_audio()
async def get_last_user_audio_record(self, user_id: int) -> bool:
"""Получает данные о количестве записей пользователя."""
count = await self.factory.audio.get_user_audio_records_count(user_id)
return bool(count)
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
"""Получает данные о названии файла."""
return await self.factory.audio.get_path_for_audio_record(user_id)
async def check_listen_audio(self, user_id: int) -> List[str]:
"""Проверяет прослушано ли аудио пользователем."""
return await self.factory.audio.check_listen_audio(user_id)
async def mark_listened_audio(self, file_name: str, user_id: int):
"""Отмечает аудио прослушанным для конкретного пользователя."""
await self.factory.audio.mark_listened_audio(file_name, user_id)
async def get_id_for_audio_record(self, user_id: int) -> int:
"""Получает следующий номер аудио сообщения пользователя."""
return await self.factory.audio.get_user_audio_records_count(user_id)
async def get_user_audio_records_count(self, user_id: int) -> int:
"""Получает количество аудио записей пользователя."""
return await self.factory.audio.get_user_audio_records_count(user_id)
async def refresh_listen_audio(self, user_id: int):
"""Очищает всю информацию о прослушанных аудио пользователем."""
await self.factory.audio.refresh_listen_audio(user_id)
async def delete_listen_count_for_user(self, user_id: int):
"""Удаляет данные о прослушанных пользователем аудио."""
await self.factory.audio.delete_listen_count_for_user(user_id)
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
"""Получает user_id пользователя по имени файла."""
return await self.factory.audio.get_user_id_by_file_name(file_name)
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
"""Получает дату добавления файла."""
return await self.factory.audio.get_date_by_file_name(file_name)
# Методы для voice bot
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool:
"""Устанавливает связь между message_id и user_id для voice bot."""
return await self.factory.audio.set_user_id_and_message_id_for_voice_bot(message_id, user_id)
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]:
"""Получает user_id пользователя по message_id для voice bot."""
return await self.factory.audio.get_user_id_by_message_id_for_voice_bot(message_id)
async def delete_audio_moderate_record(self, message_id: int) -> None:
"""Удаляет запись из таблицы audio_moderate по message_id."""
await self.factory.audio.delete_audio_moderate_record(message_id)
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
"""Получить все записи аудио сообщений."""
return await self.factory.audio.get_all_audio_records()
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
"""Удалить запись аудио сообщения по имени файла."""
await self.factory.audio.delete_audio_record_by_file_name(file_name)
# Методы для миграций
async def get_migration_version(self) -> int:
"""Получение текущей версии миграции."""
return await self.factory.migrations.get_migration_version()
async def get_current_version(self) -> Optional[int]:
"""Возвращает текущую последнюю версию миграции."""
return await self.factory.migrations.get_current_version()
async def update_version(self, new_version: int, script_name: str):
"""Обновляет версию миграций в таблице migrations."""
await self.factory.migrations.update_version(new_version, script_name)
async def create_table(self, sql_script: str):
"""Создает таблицу в базе. Используется в миграциях."""
await self.factory.migrations.create_table(sql_script)
async def update_migration_version(self, version: int, script_name: str):
"""Обновление версии миграции."""
await self.factory.migrations.update_version(version, script_name)
# Методы для voice bot welcome tracking
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
return await self.factory.users.check_voice_bot_welcome_received(user_id)
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
return await self.factory.users.mark_voice_bot_welcome_received(user_id)
# Методы для проверки целостности
async def check_database_integrity(self):
"""Проверяет целостность базы данных и очищает WAL файлы."""
await self.factory.check_database_integrity()
async def cleanup_wal_files(self):
"""Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O."""
await self.factory.cleanup_wal_files()
async def close(self):
"""Закрытие соединений."""
# Соединения закрываются в каждом методе
pass
async def fetch_one(self, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
"""Выполняет SQL запрос и возвращает один результат."""
try:
async with aiosqlite.connect(self.factory.db_path) as conn:
async with conn.execute(query, params) as cursor:
row = await cursor.fetchone()
if row:
columns = [description[0] for description in cursor.description]
return dict(zip(columns, row))
return None
except Exception as e:
self.logger.error(f"Error executing query: {e}")
return None

114
database/base.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import aiosqlite
from typing import Optional
from logs.custom_logger import logger
class DatabaseConnection:
"""Базовый класс для работы с базой данных."""
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:
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 _execute_query(self, query: str, params: tuple = ()):
"""Выполнение запроса с автоматическим закрытием соединения."""
conn = None
try:
conn = await self._get_connection()
result = await conn.execute(query, params)
await conn.commit()
return result
except Exception as e:
self.logger.error(f"Ошибка при выполнении запроса: {e}")
raise
finally:
if conn:
await conn.close()
async def _execute_query_with_result(self, query: str, params: tuple = ()):
"""Выполнение запроса с результатом и автоматическим закрытием соединения."""
conn = None
try:
conn = await self._get_connection()
result = await conn.execute(query, params)
# Получаем все результаты сразу, чтобы можно было закрыть соединение
rows = await result.fetchall()
return rows
except Exception as e:
self.logger.error(f"Ошибка при выполнении запроса: {e}")
raise
finally:
if conn:
await conn.close()
async def _execute_transaction(self, queries: list):
"""Выполнение транзакции с несколькими запросами."""
conn = None
try:
conn = await self._get_connection()
for query, params in queries:
await conn.execute(query, params)
await conn.commit()
except Exception as e:
if conn:
await conn.rollback()
self.logger.error(f"Ошибка при выполнении транзакции: {e}")
raise
finally:
if conn:
await conn.close()
async def check_database_integrity(self):
"""Проверяет целостность базы данных и очищает WAL файлы."""
conn = None
try:
conn = await self._get_connection()
result = await conn.execute("PRAGMA integrity_check")
integrity_result = await result.fetchone()
if integrity_result and integrity_result[0] == "ok":
self.logger.info("Проверка целостности базы данных прошла успешно")
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
self.logger.info("WAL файлы очищены")
else:
self.logger.warning(f"Проблемы с целостностью базы данных: {integrity_result}")
except Exception as e:
self.logger.error(f"Ошибка при проверке целостности базы данных: {e}")
raise
finally:
if conn:
await conn.close()
async def cleanup_wal_files(self):
"""Очищает WAL файлы и переключает на DELETE режим для предотвращения проблем с I/O."""
conn = None
try:
conn = await self._get_connection()
await conn.execute("PRAGMA journal_mode=DELETE")
await conn.execute("PRAGMA journal_mode=WAL")
self.logger.info("WAL файлы очищены и режим восстановлен")
except Exception as e:
self.logger.error(f"Ошибка при очистке WAL файлов: {e}")
raise
finally:
if conn:
await conn.close()

File diff suppressed because it is too large Load Diff

103
database/models.py Normal file
View File

@@ -0,0 +1,103 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List
@dataclass
class User:
"""Модель пользователя."""
user_id: int
first_name: str
full_name: str
username: Optional[str] = None
is_bot: bool = False
language_code: str = "ru"
emoji: str = "😊"
has_stickers: bool = False
date_added: Optional[str] = None
date_changed: Optional[str] = None
voice_bot_welcome_received: bool = False
@dataclass
class BlacklistUser:
"""Модель пользователя в черном списке."""
user_id: int
message_for_user: Optional[str] = None
date_to_unban: Optional[int] = None
created_at: Optional[int] = None
@dataclass
class UserMessage:
"""Модель сообщения пользователя."""
message_text: str
user_id: int
telegram_message_id: int
date: int
@dataclass
class TelegramPost:
"""Модель поста из Telegram."""
message_id: int
text: str
author_id: int
helper_text_message_id: Optional[int] = None
created_at: Optional[int] = None
@dataclass
class PostContent:
"""Модель контента поста."""
message_id: int
content_name: str
content_type: str
@dataclass
class MessageContentLink:
"""Модель связи сообщения с контентом."""
post_id: int
message_id: int
@dataclass
class Admin:
"""Модель администратора."""
user_id: int
role: str = "admin"
created_at: Optional[str] = None
@dataclass
class Migration:
"""Модель миграции."""
version: int
script_name: str
created_at: Optional[str] = None
@dataclass
class AudioMessage:
"""Модель аудио сообщения."""
file_name: str
author_id: int
date_added: str
file_id: str
listen_count: int = 0
@dataclass
class AudioListenRecord:
"""Модель записи прослушивания аудио."""
file_name: str
user_id: int
is_listen: bool = False
@dataclass
class AudioModerate:
"""Модель для voice bot."""
message_id: int
user_id: int

View File

@@ -0,0 +1,23 @@
"""
Пакет репозиториев для работы с базой данных.
Содержит репозитории для разных сущностей:
- user_repository: работа с пользователями
- blacklist_repository: работа с черным списком
- message_repository: работа с сообщениями
- post_repository: работа с постами
- admin_repository: работа с администраторами
- audio_repository: работа с аудио
"""
from .user_repository import UserRepository
from .blacklist_repository import BlacklistRepository
from .message_repository import MessageRepository
from .post_repository import PostRepository
from .admin_repository import AdminRepository
from .audio_repository import AudioRepository
__all__ = [
'UserRepository', 'BlacklistRepository', 'MessageRepository', 'PostRepository',
'AdminRepository', 'AudioRepository'
]

View File

@@ -0,0 +1,74 @@
from typing import Optional
from database.base import DatabaseConnection
from database.models import Admin
class AdminRepository(DatabaseConnection):
"""Репозиторий для работы с администраторами."""
async def create_tables(self):
"""Создание таблицы администраторов."""
# Включаем поддержку внешних ключей
await self._execute_query("PRAGMA foreign_keys = ON")
query = '''
CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER NOT NULL PRIMARY KEY,
role TEXT DEFAULT 'admin',
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(query)
self.logger.info("Таблица администраторов создана")
async def add_admin(self, admin: Admin) -> None:
"""Добавление администратора."""
query = "INSERT INTO admins (user_id, role) VALUES (?, ?)"
params = (admin.user_id, admin.role)
await self._execute_query(query, params)
self.logger.info(f"Администратор добавлен: user_id={admin.user_id}, role={admin.role}")
async def remove_admin(self, user_id: int) -> None:
"""Удаление администратора."""
query = "DELETE FROM admins WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Администратор удален: user_id={user_id}")
async def is_admin(self, user_id: int) -> bool:
"""Проверка, является ли пользователь администратором."""
query = "SELECT 1 FROM admins WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
return bool(row)
async def get_admin(self, user_id: int) -> Optional[Admin]:
"""Получение информации об администраторе."""
query = "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
return Admin(
user_id=row[0],
role=row[1],
created_at=row[2] if len(row) > 2 else None
)
return None
async def get_all_admins(self) -> list[Admin]:
"""Получение всех администраторов."""
query = "SELECT user_id, role, created_at FROM admins ORDER BY created_at DESC"
rows = await self._execute_query_with_result(query)
admins = []
for row in rows:
admin = Admin(
user_id=row[0],
role=row[1],
created_at=row[2] if len(row) > 2 else None
)
admins.append(admin)
return admins

View File

@@ -0,0 +1,238 @@
from typing import Optional, List, Dict, Any
from database.base import DatabaseConnection
from database.models import AudioMessage, AudioListenRecord, AudioModerate
from datetime import datetime
class AudioRepository(DatabaseConnection):
"""Репозиторий для работы с аудио сообщениями."""
async def enable_foreign_keys(self):
"""Включает поддержку внешних ключей."""
await self._execute_query("PRAGMA foreign_keys = ON;")
async def create_tables(self):
"""Создание таблиц для аудио."""
# Таблица аудио сообщений
audio_query = '''
CREATE TABLE IF NOT EXISTS audio_message_reference (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE,
author_id INTEGER NOT NULL,
date_added INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(audio_query)
# Таблица прослушивания аудио
listen_query = '''
CREATE TABLE IF NOT EXISTS user_audio_listens (
file_name TEXT NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (file_name, user_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(listen_query)
# Таблица для voice bot
voice_query = '''
CREATE TABLE IF NOT EXISTS audio_moderate (
user_id INTEGER NOT NULL,
message_id INTEGER,
PRIMARY KEY (user_id, message_id),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(voice_query)
self.logger.info("Таблицы для аудио созданы")
async def add_audio_record(self, audio: AudioMessage) -> None:
"""Добавляет информацию о войсе пользователя."""
query = """
INSERT INTO audio_message_reference (file_name, author_id, date_added)
VALUES (?, ?, ?)
"""
# Преобразуем datetime в UNIX timestamp если нужно
if isinstance(audio.date_added, str):
date_timestamp = int(datetime.fromisoformat(audio.date_added).timestamp())
elif isinstance(audio.date_added, datetime):
date_timestamp = int(audio.date_added.timestamp())
else:
date_timestamp = audio.date_added
params = (audio.file_name, audio.author_id, date_timestamp)
await self._execute_query(query, params)
self.logger.info(f"Аудио добавлено: file_name={audio.file_name}, author_id={audio.author_id}")
async def add_audio_record_simple(self, file_name: str, user_id: int, date_added) -> None:
"""Добавляет информацию о войсе пользователя (упрощенная версия)."""
query = """
INSERT INTO audio_message_reference (file_name, author_id, date_added)
VALUES (?, ?, ?)
"""
# Преобразуем datetime в UNIX timestamp если нужно
if isinstance(date_added, str):
date_timestamp = int(datetime.fromisoformat(date_added).timestamp())
elif isinstance(date_added, datetime):
date_timestamp = int(date_added.timestamp())
else:
date_timestamp = date_added
params = (file_name, user_id, date_timestamp)
await self._execute_query(query, params)
self.logger.info(f"Аудио добавлено: file_name={file_name}, user_id={user_id}")
async def get_last_date_audio(self) -> Optional[int]:
"""Получает дату последнего войса."""
query = "SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
rows = await self._execute_query_with_result(query)
row = rows[0] if rows else None
if row:
self.logger.info(f"Последняя дата аудио: {row[0]}")
return row[0]
return None
async def get_user_audio_records_count(self, user_id: int) -> int:
"""Получает количество записей пользователя."""
query = "SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
return row[0] if row else 0
async def get_path_for_audio_record(self, user_id: int) -> Optional[str]:
"""Получает название последнего файла пользователя."""
query = """
SELECT file_name FROM audio_message_reference
WHERE author_id = ? ORDER BY date_added DESC LIMIT 1
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
return row[0] if row else None
async def check_listen_audio(self, user_id: int) -> List[str]:
"""Проверяет непрослушанные аудио для пользователя."""
query = """
SELECT l.file_name
FROM audio_message_reference a
LEFT JOIN user_audio_listens l ON l.file_name = a.file_name
WHERE l.user_id = ? AND l.file_name IS NOT NULL
"""
listened_files = await self._execute_query_with_result(query, (user_id,))
# Получаем все аудио, кроме созданных пользователем
all_audio_query = 'SELECT file_name FROM audio_message_reference WHERE author_id <> ?'
all_files = await self._execute_query_with_result(all_audio_query, (user_id,))
# Находим непрослушанные
listened_set = {row[0] for row in listened_files}
all_set = {row[0] for row in all_files}
new_files = list(all_set - listened_set)
self.logger.info(f"Найдено {len(new_files)} непрослушанных аудио для пользователя {user_id}")
return new_files
async def mark_listened_audio(self, file_name: str, user_id: int) -> None:
"""Отмечает аудио прослушанным для пользователя."""
query = "INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)"
params = (file_name, user_id)
await self._execute_query(query, params)
self.logger.info(f"Аудио {file_name} отмечено как прослушанное для пользователя {user_id}")
async def get_user_id_by_file_name(self, file_name: str) -> Optional[int]:
"""Получает user_id пользователя по имени файла."""
query = "SELECT author_id FROM audio_message_reference WHERE file_name = ?"
rows = await self._execute_query_with_result(query, (file_name,))
row = rows[0] if rows else None
if row:
user_id = row[0]
self.logger.info(f"Получен user_id {user_id} для файла {file_name}")
return user_id
return None
async def get_date_by_file_name(self, file_name: str) -> Optional[str]:
"""Получает дату добавления файла."""
query = "SELECT date_added FROM audio_message_reference WHERE file_name = ?"
rows = await self._execute_query_with_result(query, (file_name,))
row = rows[0] if rows else None
if row:
date_added = row[0]
# Преобразуем UNIX timestamp в читаемую дату
readable_date = datetime.fromtimestamp(date_added).strftime('%d.%m.%Y %H:%M')
self.logger.info(f"Получена дата {readable_date} для файла {file_name}")
return readable_date
return None
async def refresh_listen_audio(self, user_id: int) -> None:
"""Очищает всю информацию о прослушанных аудио пользователем."""
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Очищены записи прослушивания для пользователя {user_id}")
async def delete_listen_count_for_user(self, user_id: int) -> None:
"""Удаляет данные о прослушанных пользователем аудио."""
query = "DELETE FROM user_audio_listens WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Удалены записи прослушивания для пользователя {user_id}")
# Методы для voice bot
async def set_user_id_and_message_id_for_voice_bot(self, message_id: int, user_id: int) -> bool:
"""Устанавливает связь между message_id и user_id для voice bot."""
try:
query = "INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)"
params = (user_id, message_id)
await self._execute_query(query, params)
self.logger.info(f"Связь установлена: message_id={message_id}, user_id={user_id}")
return True
except Exception as e:
self.logger.error(f"Ошибка установки связи: {e}")
return False
async def get_user_id_by_message_id_for_voice_bot(self, message_id: int) -> Optional[int]:
"""Получает user_id пользователя по message_id для voice bot."""
query = "SELECT user_id FROM audio_moderate WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None
if row:
user_id = row[0]
self.logger.info(f"Получен user_id {user_id} для message_id {message_id}")
return user_id
return None
async def delete_audio_moderate_record(self, message_id: int) -> None:
"""Удаляет запись из таблицы audio_moderate по message_id."""
query = "DELETE FROM audio_moderate WHERE message_id = ?"
await self._execute_query(query, (message_id,))
self.logger.info(f"Удалена запись из audio_moderate для message_id {message_id}")
async def get_all_audio_records(self) -> List[Dict[str, Any]]:
"""Получить все записи аудио сообщений."""
query = "SELECT file_name, author_id, date_added FROM audio_message_reference"
rows = await self._execute_query_with_result(query)
records = []
for row in rows:
records.append({
'file_name': row[0],
'author_id': row[1],
'date_added': row[2]
})
self.logger.info(f"Получено {len(records)} записей аудио сообщений")
return records
async def delete_audio_record_by_file_name(self, file_name: str) -> None:
"""Удалить запись аудио сообщения по имени файла."""
query = "DELETE FROM audio_message_reference WHERE file_name = ?"
await self._execute_query(query, (file_name,))
self.logger.info(f"Удалена запись аудио сообщения: {file_name}")

View File

@@ -0,0 +1,116 @@
from typing import Optional, List, Dict
from database.base import DatabaseConnection
from database.models import BlacklistUser
class BlacklistRepository(DatabaseConnection):
"""Репозиторий для работы с черным списком."""
async def create_tables(self):
"""Создание таблицы черного списка."""
query = '''
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT,
date_to_unban INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(query)
self.logger.info("Таблица черного списка создана")
async def add_user(self, blacklist_user: BlacklistUser) -> None:
"""Добавляет пользователя в черный список."""
query = """
INSERT INTO blacklist (user_id, message_for_user, date_to_unban)
VALUES (?, ?, ?)
"""
params = (blacklist_user.user_id, blacklist_user.message_for_user, blacklist_user.date_to_unban)
await self._execute_query(query, params)
self.logger.info(f"Пользователь добавлен в черный список: user_id={blacklist_user.user_id}")
async def remove_user(self, user_id: int) -> bool:
"""Удаляет пользователя из черного списка."""
try:
query = "DELETE FROM blacklist WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Пользователь с идентификатором {user_id} успешно удален из черного списка.")
return True
except Exception as e:
self.logger.error(f"Ошибка удаления пользователя с идентификатором {user_id} "
f"из таблицы blacklist. Ошибка: {str(e)}")
return False
async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли запись с данным user_id в blacklist."""
query = "SELECT 1 FROM blacklist WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
self.logger.info(f"Существует ли пользователь: user_id={user_id} Итог: {rows}")
return bool(rows)
async def get_user(self, user_id: int) -> Optional[BlacklistUser]:
"""Возвращает информацию о пользователе в черном списке по user_id."""
query = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
return BlacklistUser(
user_id=row[0],
message_for_user=row[1],
date_to_unban=row[2],
created_at=row[3]
)
return None
async def get_all_users(self, offset: int = 0, limit: int = 10) -> List[BlacklistUser]:
"""Возвращает список пользователей в черном списке."""
query = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?"
rows = await self._execute_query_with_result(query, (offset, limit))
users = []
for row in rows:
users.append(BlacklistUser(
user_id=row[0],
message_for_user=row[1],
date_to_unban=row[2],
created_at=row[3]
))
self.logger.info(f"Получен список пользователей в черном списке (offset={offset}, limit={limit}): {len(users)}")
return users
async def get_all_users_no_limit(self) -> List[BlacklistUser]:
"""Возвращает список всех пользователей в черном списке без лимитов."""
query = "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist"
rows = await self._execute_query_with_result(query)
users = []
for row in rows:
users.append(BlacklistUser(
user_id=row[0],
message_for_user=row[1],
date_to_unban=row[2],
created_at=row[3]
))
self.logger.info(f"Получен список всех пользователей в черном списке: {len(users)}")
return users
async def get_users_for_unblock_today(self, current_timestamp: int) -> Dict[int, int]:
"""Возвращает список пользователей, у которых истек срок блокировки."""
query = "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
rows = await self._execute_query_with_result(query, (current_timestamp,))
users = {user_id: user_id for user_id, in rows}
self.logger.info(f"Получен список пользователей для разблокировки: {users}")
return users
async def get_count(self) -> int:
"""Получение количества пользователей в черном списке."""
query = "SELECT COUNT(*) FROM blacklist"
rows = await self._execute_query_with_result(query)
row = rows[0] if rows else None
return row[0] if row else 0

View File

@@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from database.base import DatabaseConnection
from database.models import UserMessage
class MessageRepository(DatabaseConnection):
"""Репозиторий для работы с сообщениями пользователей."""
async def create_tables(self):
"""Создание таблицы сообщений пользователей."""
query = '''
CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT,
user_id INTEGER,
telegram_message_id INTEGER NOT NULL,
date INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(query)
self.logger.info("Таблица сообщений пользователей создана")
async def add_message(self, message: UserMessage) -> None:
"""Добавление сообщения пользователя."""
if message.date is None:
message.date = int(datetime.now().timestamp())
query = """
INSERT INTO user_messages (message_text, user_id, telegram_message_id, date)
VALUES (?, ?, ?, ?)
"""
params = (message.message_text, message.user_id, message.telegram_message_id, message.date)
await self._execute_query(query, params)
self.logger.info(f"Новое сообщение добавлено: telegram_message_id={message.telegram_message_id}")
async def get_user_by_message_id(self, message_id: int) -> Optional[int]:
"""Получение пользователя по message_id."""
query = "SELECT user_id FROM user_messages WHERE telegram_message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None
return row[0] if row else None

View File

@@ -0,0 +1,150 @@
from datetime import datetime
from typing import Optional, List, Tuple
from database.base import DatabaseConnection
from database.models import TelegramPost, PostContent, MessageContentLink
class PostRepository(DatabaseConnection):
"""Репозиторий для работы с постами из Telegram."""
async def create_tables(self):
"""Создание таблиц для постов."""
# Таблица постов из Telegram
post_query = '''
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
message_id INTEGER NOT NULL PRIMARY KEY,
text TEXT,
helper_text_message_id INTEGER,
author_id INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
'''
await self._execute_query(post_query)
# Таблица контента постов
content_query = '''
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
'''
await self._execute_query(content_query)
# Таблица связи сообщений с контентом
link_query = '''
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
'''
await self._execute_query(link_query)
self.logger.info("Таблицы для постов созданы")
async def add_post(self, post: TelegramPost) -> None:
"""Добавление поста."""
if not post.created_at:
post.created_at = int(datetime.now().timestamp())
query = """
INSERT INTO post_from_telegram_suggest (message_id, text, author_id, created_at)
VALUES (?, ?, ?, ?)
"""
params = (post.message_id, post.text, post.author_id, post.created_at)
await self._execute_query(query, params)
self.logger.info(f"Пост добавлен: message_id={post.message_id}")
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None:
"""Обновление helper сообщения."""
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (helper_message_id, message_id))
async def add_post_content(self, post_id: int, message_id: int, content_name: str, content_type: str) -> bool:
"""Добавление контента поста."""
try:
# Сначала добавляем связь
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id))
# Затем добавляем контент
content_query = """
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
VALUES (?, ?, ?)
"""
await self._execute_query(content_query, (message_id, content_name, content_type))
self.logger.info(f"Контент поста добавлен: post_id={post_id}, message_id={message_id}")
return True
except Exception as e:
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
return False
async def get_post_content_by_helper_id(self, helper_message_id: int) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id."""
query = """
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 = ?
"""
post_content = await self._execute_query_with_result(query, (helper_message_id,))
self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
return post_content
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id."""
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None
if row:
self.logger.info(f"Получен текст поста для helper_message_id={helper_message_id}")
return row[0]
return None
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
"""Получает ID сообщений по helper_text_message_id."""
query = """
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 = ?
"""
rows = await self._execute_query_with_result(query, (helper_message_id,))
post_ids = [row[0] for row in rows]
self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов")
return post_ids
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None
if row:
author_id = row[0]
self.logger.info(f"Получен author_id: {author_id} для message_id={message_id}")
return author_id
return None
async def get_author_id_by_helper_message_id(self, helper_message_id: int) -> Optional[int]:
"""Получает ID автора по helper_text_message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None
if row:
author_id = row[0]
self.logger.info(f"Получен author_id: {author_id} для helper_message_id={helper_message_id}")
return author_id
return None

View File

@@ -0,0 +1,258 @@
from datetime import datetime
from typing import Optional, List, Dict, Any
from database.base import DatabaseConnection
from database.models import User
class UserRepository(DatabaseConnection):
"""Репозиторий для работы с пользователями."""
async def create_tables(self):
"""Создание таблицы пользователей."""
query = '''
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
full_name TEXT,
username TEXT,
is_bot BOOLEAN DEFAULT 0,
language_code TEXT,
has_stickers BOOLEAN DEFAULT 0 NOT NULL,
emoji TEXT,
date_added INTEGER NOT NULL,
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
)
'''
await self._execute_query(query)
self.logger.info("Таблица пользователей создана")
async def user_exists(self, user_id: int) -> bool:
"""Проверяет, существует ли пользователь в базе данных."""
query = "SELECT user_id FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
self.logger.info(f"Проверка существования пользователя: user_id={user_id}, результат={rows}")
return bool(len(rows))
async def add_user(self, user: User) -> None:
"""Добавление нового пользователя с защитой от дублирования."""
if not user.date_added:
user.date_added = int(datetime.now().timestamp())
if not user.date_changed:
user.date_changed = int(datetime.now().timestamp())
query = """
INSERT OR IGNORE INTO our_users (user_id, first_name, full_name, username, is_bot,
language_code, emoji, has_stickers, date_added, date_changed, voice_bot_welcome_received)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
params = (user.user_id, user.first_name, user.full_name, user.username,
user.is_bot, user.language_code, user.emoji, user.has_stickers,
user.date_added, user.date_changed, user.voice_bot_welcome_received)
await self._execute_query(query, params)
self.logger.info(f"Пользователь обработан (создан или уже существует): {user.user_id}")
async def get_user_info(self, user_id: int) -> Optional[User]:
"""Получение информации о пользователе."""
query = "SELECT username, full_name, has_stickers, emoji FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
return User(
user_id=user_id,
first_name="", # Не получаем из этого запроса
full_name=row[1],
username=row[0],
has_stickers=bool(row[2]) if row[2] is not None else False,
emoji=row[3]
)
return None
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID."""
query = "SELECT * FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
return User(
user_id=row[0],
first_name=row[1],
full_name=row[2],
username=row[3],
is_bot=bool(row[4]),
language_code=row[5],
has_stickers=bool(row[6]),
emoji=row[7],
date_added=row[8],
date_changed=row[9],
voice_bot_welcome_received=bool(row[10]) if len(row) > 10 else False
)
return None
async def get_username(self, user_id: int) -> Optional[str]:
"""Возвращает username пользователя."""
query = "SELECT username FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
username = row[0]
self.logger.info(f"Username пользователя найден: user_id={user_id}, username={username}")
return username
return None
async def get_user_id_by_username(self, username: str) -> Optional[int]:
"""Возвращает user_id пользователя по username."""
query = "SELECT user_id FROM our_users WHERE username = ?"
rows = await self._execute_query_with_result(query, (username,))
row = rows[0] if rows else None
if row:
user_id = row[0]
self.logger.info(f"User_id пользователя найден: username={username}, user_id={user_id}")
return user_id
return None
async def get_full_name_by_id(self, user_id: int) -> Optional[str]:
"""Возвращает full_name пользователя."""
query = "SELECT full_name FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
full_name = row[0]
self.logger.info(f"Full_name пользователя найден: user_id={user_id}, full_name={full_name}")
return full_name
return None
async def get_user_first_name(self, user_id: int) -> Optional[str]:
"""Возвращает first_name пользователя."""
query = "SELECT first_name FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
first_name = row[0]
self.logger.info(f"First_name пользователя найден: user_id={user_id}, first_name={first_name}")
return first_name
return None
async def get_all_user_ids(self) -> List[int]:
"""Возвращает список всех user_id."""
query = "SELECT user_id FROM our_users"
rows = await self._execute_query_with_result(query)
user_ids = [row[0] for row in rows]
self.logger.info(f"Получен список всех user_id: {user_ids}")
return user_ids
async def get_last_users(self, limit: int = 30) -> List[tuple]:
"""Получение последних пользователей."""
query = "SELECT full_name, user_id FROM our_users ORDER BY date_changed DESC LIMIT ?"
rows = await self._execute_query_with_result(query, (limit,))
return rows
async def update_user_date(self, user_id: int) -> None:
"""Обновление даты последнего изменения пользователя."""
date_changed = int(datetime.now().timestamp())
query = "UPDATE our_users SET date_changed = ? WHERE user_id = ?"
await self._execute_query(query, (date_changed, user_id))
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None) -> None:
"""Обновление информации о пользователе."""
if username and full_name:
query = "UPDATE our_users SET username = ?, full_name = ? WHERE user_id = ?"
params = (username, full_name, user_id)
elif username:
query = "UPDATE our_users SET username = ? WHERE user_id = ?"
params = (username, user_id)
elif full_name:
query = "UPDATE our_users SET full_name = ? WHERE user_id = ?"
params = (full_name, user_id)
else:
return
await self._execute_query(query, params)
async def update_user_emoji(self, user_id: int, emoji: str) -> None:
"""Обновление эмодзи пользователя."""
query = "UPDATE our_users SET emoji = ? WHERE user_id = ?"
await self._execute_query(query, (emoji, user_id))
async def update_stickers_info(self, user_id: int) -> None:
"""Обновление информации о стикерах."""
query = "UPDATE our_users SET has_stickers = 1 WHERE user_id = ?"
await self._execute_query(query, (user_id,))
async def get_stickers_info(self, user_id: int) -> bool:
"""Получение информации о стикерах."""
query = "SELECT has_stickers FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
return bool(row[0]) if row and row[0] is not None else False
async def check_emoji_exists(self, emoji: str) -> bool:
"""Проверка существования эмодзи."""
query = "SELECT 1 FROM our_users WHERE emoji = ?"
rows = await self._execute_query_with_result(query, (emoji,))
row = rows[0] if rows else None
return bool(row)
async def get_user_emoji(self, user_id: int) -> str:
"""
Получает эмодзи пользователя.
Args:
user_id: ID пользователя.
Returns:
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
"""
query = "SELECT emoji FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row and row[0]:
emoji = row[0]
self.logger.info(f"Эмодзи пользователя найден: user_id={user_id}, emoji={emoji}")
return str(emoji)
else:
self.logger.info(f"Эмодзи пользователя не найден: user_id={user_id}")
return "Смайл еще не определен"
async def check_emoji_for_user(self, user_id: int) -> str:
"""
Проверяет, есть ли уже у пользователя назначенный emoji.
Args:
user_id: ID пользователя.
Returns:
str: Эмодзи пользователя или "Смайл еще не определен" если не установлен.
"""
return await self.get_user_emoji(user_id)
async def check_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Проверяет, получал ли пользователь приветственное сообщение от voice_bot."""
query = "SELECT voice_bot_welcome_received FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
welcome_received = bool(row[0])
self.logger.info(f"Пользователь {user_id} получал приветствие: {welcome_received}")
return welcome_received
return False
async def mark_voice_bot_welcome_received(self, user_id: int) -> bool:
"""Отмечает, что пользователь получил приветственное сообщение от voice_bot."""
try:
query = "UPDATE our_users SET voice_bot_welcome_received = 1 WHERE user_id = ?"
await self._execute_query(query, (user_id,))
self.logger.info(f"Пользователь {user_id} отмечен как получивший приветствие")
return True
except Exception as e:
self.logger.error(f"Ошибка при отметке получения приветствия: {e}")
return False

View File

@@ -0,0 +1,79 @@
from typing import Optional
from database.repositories.user_repository import UserRepository
from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.message_repository import MessageRepository
from database.repositories.post_repository import PostRepository
from database.repositories.admin_repository import AdminRepository
from database.repositories.audio_repository import AudioRepository
class RepositoryFactory:
"""Фабрика для создания репозиториев."""
def __init__(self, db_path: str):
self.db_path = db_path
self._user_repo: Optional[UserRepository] = None
self._blacklist_repo: Optional[BlacklistRepository] = None
self._message_repo: Optional[MessageRepository] = None
self._post_repo: Optional[PostRepository] = None
self._admin_repo: Optional[AdminRepository] = None
self._audio_repo: Optional[AudioRepository] = None
@property
def users(self) -> UserRepository:
"""Возвращает репозиторий пользователей."""
if self._user_repo is None:
self._user_repo = UserRepository(self.db_path)
return self._user_repo
@property
def blacklist(self) -> BlacklistRepository:
"""Возвращает репозиторий черного списка."""
if self._blacklist_repo is None:
self._blacklist_repo = BlacklistRepository(self.db_path)
return self._blacklist_repo
@property
def messages(self) -> MessageRepository:
"""Возвращает репозиторий сообщений."""
if self._message_repo is None:
self._message_repo = MessageRepository(self.db_path)
return self._message_repo
@property
def posts(self) -> PostRepository:
"""Возвращает репозиторий постов."""
if self._post_repo is None:
self._post_repo = PostRepository(self.db_path)
return self._post_repo
@property
def admins(self) -> AdminRepository:
"""Возвращает репозиторий администраторов."""
if self._admin_repo is None:
self._admin_repo = AdminRepository(self.db_path)
return self._admin_repo
@property
def audio(self) -> AudioRepository:
"""Возвращает репозиторий аудио."""
if self._audio_repo is None:
self._audio_repo = AudioRepository(self.db_path)
return self._audio_repo
async def create_all_tables(self):
"""Создает все таблицы в базе данных."""
await self.users.create_tables()
await self.blacklist.create_tables()
await self.messages.create_tables()
await self.posts.create_tables()
await self.admins.create_tables()
await self.audio.create_tables()
async def check_database_integrity(self):
"""Проверяет целостность базы данных."""
await self.users.check_database_integrity()
async def cleanup_wal_files(self):
"""Очищает WAL файлы."""
await self.users.cleanup_wal_files()

113
database/schema.sql Normal file
View File

@@ -0,0 +1,113 @@
-- Telegram Helper Bot Database Schema
-- Compatible with Docker container deployment
-- IMPORTANT: Enable foreign key support after each database connection
-- PRAGMA foreign_keys = ON;
-- Note: sqlite_sequence table is automatically created by SQLite for AUTOINCREMENT fields
-- No need to create it manually
-- Users who have listened to audio messages
CREATE TABLE IF NOT EXISTS user_audio_listens (
file_name TEXT NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (file_name, user_id),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Reference table for audio messages
CREATE TABLE IF NOT EXISTS audio_message_reference (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE,
author_id INTEGER NOT NULL,
date_added INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Bot administrators
CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER NOT NULL PRIMARY KEY,
role TEXT,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- User blacklist for banned users
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT,
date_to_unban INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- User message history
CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT,
user_id INTEGER,
telegram_message_id INTEGER NOT NULL,
date INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Suggested posts from Telegram
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
message_id INTEGER NOT NULL PRIMARY KEY,
text TEXT,
helper_text_message_id INTEGER,
author_id INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Links between posts and content
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
);
-- Content associated with Telegram posts
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
);
-- Bot users information (user_id is now PRIMARY KEY)
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
full_name TEXT,
username TEXT,
is_bot BOOLEAN DEFAULT 0,
language_code TEXT,
has_stickers BOOLEAN DEFAULT 0 NOT NULL,
emoji TEXT,
date_added INTEGER NOT NULL,
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
);
-- Audio moderation tracking
CREATE TABLE IF NOT EXISTS audio_moderate (
user_id INTEGER NOT NULL,
message_id INTEGER,
PRIMARY KEY (user_id, message_id),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Create indexes for better performance
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_author_id ON audio_message_reference(author_id);
CREATE INDEX IF NOT EXISTS idx_user_messages_user_id ON user_messages(user_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_author_id ON post_from_telegram_suggest(author_id);
CREATE INDEX IF NOT EXISTS idx_blacklist_date_to_unban ON blacklist(date_to_unban);
CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);
CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed);

29
env.example Normal file
View File

@@ -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 (Centralized Prometheus)
METRICS_HOST=0.0.0.0
METRICS_PORT=8080
# Logging
LOG_LEVEL=INFO
LOG_RETENTION_DAYS=30

View File

@@ -0,0 +1 @@
# Config package

View File

@@ -0,0 +1,129 @@
"""
Конфигурация для rate limiting
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class RateLimitSettings:
"""Настройки rate limiting для разных типов сообщений"""
# Основные настройки
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 2 # Максимум 2 сообщения подряд
retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry
max_retry_delay: float = 30.0 # Максимальная задержка между попытками
max_retries: int = 3 # Максимальное количество повторных попыток
# Специальные настройки для разных типов сообщений
voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений
media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
# Настройки для разных типов чатов
private_chat_multiplier: float = 1.0 # Множитель для приватных чатов
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
channel_multiplier: float = 0.6 # Множитель для каналов
# Глобальные ограничения
global_messages_per_second: float = 10.0 # Максимум 10 сообщений в секунду глобально
global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально
# Конфигурации для разных сценариев использования
DEVELOPMENT_CONFIG = RateLimitSettings(
messages_per_second=1.0, # Более мягкие ограничения для разработки
burst_limit=3,
retry_after_multiplier=1.2,
max_retry_delay=15.0,
max_retries=2
)
PRODUCTION_CONFIG = RateLimitSettings(
messages_per_second=0.5, # Строгие ограничения для продакшена
burst_limit=2,
retry_after_multiplier=1.5,
max_retry_delay=30.0,
max_retries=3,
voice_message_delay=2.5,
media_message_delay=2.0,
text_message_delay=1.5
)
STRICT_CONFIG = RateLimitSettings(
messages_per_second=0.3, # Очень строгие ограничения
burst_limit=1,
retry_after_multiplier=2.0,
max_retry_delay=60.0,
max_retries=5,
voice_message_delay=3.0,
media_message_delay=2.5,
text_message_delay=2.0
)
def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
"""
Получает конфигурацию rate limiting в зависимости от окружения
Args:
environment: Окружение ('development', 'production', 'strict')
Returns:
RateLimitSettings: Конфигурация для указанного окружения
"""
configs = {
"development": DEVELOPMENT_CONFIG,
"production": PRODUCTION_CONFIG,
"strict": STRICT_CONFIG
}
return configs.get(environment, PRODUCTION_CONFIG)
def get_adaptive_config(
current_error_rate: float,
base_config: Optional[RateLimitSettings] = None
) -> RateLimitSettings:
"""
Получает адаптивную конфигурацию на основе текущего уровня ошибок
Args:
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
base_config: Базовая конфигурация
Returns:
RateLimitSettings: Адаптированная конфигурация
"""
if base_config is None:
base_config = PRODUCTION_CONFIG
# Если уровень ошибок высокий, ужесточаем ограничения
if current_error_rate > 0.1: # Более 10% ошибок
return RateLimitSettings(
messages_per_second=base_config.messages_per_second * 0.5,
burst_limit=max(1, base_config.burst_limit - 1),
retry_after_multiplier=base_config.retry_after_multiplier * 1.5,
max_retry_delay=base_config.max_retry_delay * 1.5,
max_retries=base_config.max_retries + 1,
voice_message_delay=base_config.voice_message_delay * 1.5,
media_message_delay=base_config.media_message_delay * 1.3,
text_message_delay=base_config.text_message_delay * 1.2
)
# Если уровень ошибок низкий, можно немного ослабить ограничения
elif current_error_rate < 0.01: # Менее 1% ошибок
return RateLimitSettings(
messages_per_second=base_config.messages_per_second * 1.2,
burst_limit=base_config.burst_limit + 1,
retry_after_multiplier=base_config.retry_after_multiplier * 0.9,
max_retry_delay=base_config.max_retry_delay * 0.8,
max_retries=max(1, base_config.max_retries - 1),
voice_message_delay=base_config.voice_message_delay * 0.8,
media_message_delay=base_config.media_message_delay * 0.9,
text_message_delay=base_config.text_message_delay * 0.9
)
# Возвращаем базовую конфигурацию
return base_config

View File

@@ -1 +1,37 @@
from .admin_handlers import admin_router 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'
]

View File

@@ -1,50 +1,90 @@
import traceback
import html
from aiogram import Router, types, F 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 aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin, create_keyboard_with_pagination, \ from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_days, create_keyboard_for_approve_ban, create_keyboard_for_ban_reason get_reply_keyboard_admin,
from helper_bot.utils.base_dependency_factory import get_global_instance create_keyboard_with_pagination,
from helper_bot.utils.helper_func import check_access, add_days_to_date, get_banned_users_buttons, get_banned_users_list 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 from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time
)
# Создаем роутер с middleware для проверки доступа
admin_router = Router() 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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command('admin') Command('admin')
) )
async def admin_panel(message: types.Message, state: FSMContext): @track_time("admin_panel", "admin_handlers")
@track_errors("admin_handlers", "admin_panel")
async def admin_panel(
message: types.Message,
state: FSMContext,
**kwargs
):
"""Главное меню администратора"""
try: try:
if check_access(message.from_user.id, BotDB):
await state.set_state("ADMIN") await state.set_state("ADMIN")
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await message.answer("Добро пожаловать в админку. Выбери что хочешь:", await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
reply_markup=markup)
else:
await message.answer('Доступ запрещен, досвидания!')
except Exception as e: except Exception as e:
logger.error(f"Ошибка при запуске админ панели: {e}") await handle_admin_error(message, e, state, "admin_panel")
await message.bot.send_message(IMPORTANT_LOGS,
f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}')
# ============================================================================
# ХЕНДЛЕР ОТМЕНЫ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"),
F.text == 'Отменить'
)
@track_time("cancel_ban_process", "admin_handlers")
@track_errors("admin_handlers", "cancel_ban_process")
async def cancel_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
"""Отмена процесса блокировки"""
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( @admin_router.message(
@@ -52,189 +92,33 @@ async def admin_panel(message: types.Message, state: FSMContext):
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Бан (Список)' F.text == 'Бан (Список)'
) )
async def get_last_users(message: types.Message): @track_time("get_last_users", "admin_handlers")
logger.info( @track_errors("admin_handlers", "get_last_users")
f"Попытка получения списка последних пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") @db_query_time("get_last_users", "users", "select")
list_users = BotDB.get_last_users_from_db() async def get_last_users(
keyboard = create_keyboard_with_pagination(1, len(list_users), list_users, 'ban') message: types.Message,
await message.answer(text="Список пользователей которые последними обращались к боту", state: FSMContext,
reply_markup=keyboard) bot_db: MagicData("bot_db")
):
"""Получение списка последних пользователей"""
@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"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {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):
try: try:
user_id = int(message.text) logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}")
logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}") admin_service = AdminService(bot_db)
users = await admin_service.get_last_users()
# Проверяем, существует ли пользователь в базе # Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
user_info = BotDB.get_user_info_by_id(user_id) users_data = [
if not user_info: (user.full_name, user.user_id)
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") for user in users
await state.set_state('ADMIN') ]
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup)
return
user_name = user_info.get('username', 'Неизвестно') keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban')
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))
await message.answer( await message.answer(
text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\n" text="Список пользователей которые последними обращались к боту",
f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", reply_markup=keyboard
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"<b>Выбран пользователь из пересланного сообщения:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\n"
f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup)
await state.set_state('BAN_2')
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке пересланного сообщения: {e}") await handle_admin_error(message, e, state, "get_last_users")
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("Пожалуйста, перешлите сообщение от пользователя, которого хотите заблокировать. Обычное сообщение не подходит.")
@admin_router.message( @admin_router.message(
@@ -242,80 +126,248 @@ async def ban_by_forward_invalid(message: types.Message, state: FSMContext):
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Разбан (список)' F.text == 'Разбан (список)'
) )
async def get_banned_users(message): @track_time("get_banned_users", "admin_handlers")
logger.info( @track_errors("admin_handlers", "get_banned_users")
f"Попытка получения списка заблокированных пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") @db_query_time("get_banned_users", "users", "select")
message_text = get_banned_users_list(0, BotDB) async def get_banned_users(
buttons_list = get_banned_users_buttons(BotDB) 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 = await admin_service.get_banned_users_for_display(0)
if buttons_list: if buttons_list:
k = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock')
await message.answer(text=message_text, reply_markup=k) await message.answer(text=message_text, reply_markup=keyboard)
else: else:
await message.answer(text="В списке забанненых пользователей никого нет") 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("ADMIN"),
F.text.in_(['Бан по нику', 'Бан по ID'])
)
@track_time("start_ban_process", "admin_handlers")
@track_errors("admin_handlers", "start_ban_process")
async def start_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
"""Начало процесса блокировки пользователя"""
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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_2") StateFilter("AWAIT_BAN_TARGET")
) )
async def ban_user_step_2(message: types.Message, state: FSMContext): @track_time("process_ban_target", "admin_handlers")
@track_errors("admin_handlers", "process_ban_target")
async def process_ban_target(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
):
"""Обработка введенного username/ID для блокировки"""
logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
try:
user_data = await state.get_data() user_data = await state.get_data()
logger.info(f"Переход на шаг 2 бана пользователя. Словарь с данными для бана: {user_data})") ban_type = user_data.get('ban_type')
await state.update_data(message_for_user=message.text) admin_service = AdminService(bot_db)
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")
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
@admin_router.message( # Определяем пользователя
ChatTypeFilter(chat_type=["private"]), if ban_type == "username":
StateFilter("BAN_3") logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}")
user = await admin_service.get_user_by_username(message.text)
if not user:
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден")
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
await return_to_admin_menu(message, state)
return
else: # ban_type == "id"
try:
logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}")
user_id = await admin_service.validate_user_input(message.text)
user = await admin_service.get_user_by_id(user_id)
if not user:
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных")
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.")
await return_to_admin_menu(message, state)
return
except InvalidInputError as e:
logger.error(f"process_ban_target: Ошибка валидации ID: {e}")
await message.answer(str(e))
await return_to_admin_menu(message, state)
return
logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}")
# Сохраняем данные пользователя
await state.update_data(
target_user_id=user.user_id,
target_username=user.username,
target_full_name=user.full_name
) )
async def ban_user_step_3(message: types.Message, state: FSMContext):
logger.info(f"ban_user_step_3. Расчет даты разбана. Входные данные {message.text}") # Показываем информацию о пользователе и запрашиваем причину
if message.text != 'Навсегда': user_info = format_user_info(user.user_id, user.username, user.full_name)
count_days = int(message.text) markup = create_keyboard_for_ban_reason()
date_to_unban = add_days_to_date(count_days) logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}")
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( await message.answer(
f"Необходимо подтверждение:\nПользователь:{user_data['user_id']}\nПричина бана:{safe_message_for_user}\nСрок бана:{safe_date_to_unban}", text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup) reply_markup=markup
await state.set_state("BAN_FINAL") )
await state.set_state('AWAIT_BAN_DETAILS')
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
except Exception as e:
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_target")
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_FINAL"), StateFilter("AWAIT_BAN_DETAILS")
)
@track_time("process_ban_reason", "admin_handlers")
@track_errors("admin_handlers", "process_ban_reason")
async def process_ban_reason(
message: types.Message,
state: FSMContext,
**kwargs
):
"""Обработка причины блокировки"""
logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
try:
# Проверяем текущее состояние
current_state = await state.get_state()
logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
# Проверяем данные состояния
state_data = await state.get_data()
logger.info(f"process_ban_reason: Данные состояния: {state_data}")
logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}")
await state.update_data(ban_reason=message.text)
markup = create_keyboard_for_ban_days()
safe_reason = escape_html(message.text)
logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}")
await message.answer(
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
reply_markup=markup
)
await state.set_state('AWAIT_BAN_DURATION')
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
except Exception as e:
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_reason")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_DURATION")
)
@track_time("process_ban_duration", "admin_handlers")
@track_errors("admin_handlers", "process_ban_duration")
async def process_ban_duration(
message: types.Message,
state: FSMContext,
**kwargs
):
"""Обработка срока блокировки"""
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 == 'Подтвердить' F.text == 'Подтвердить'
) )
async def approve_ban(message: types.Message, state: FSMContext): @track_time("confirm_ban", "admin_handlers")
@track_errors("admin_handlers", "confirm_ban")
async def confirm_ban(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
**kwargs
):
"""Подтверждение блокировки пользователя"""
try:
user_data = await state.get_data() user_data = await state.get_data()
logger.info(f"Переход на финальный шаг бана пользователя. Словарь с данными для бана: {user_data})") admin_service = AdminService(bot_db)
exists = BotDB.check_user_in_blacklist(user_data['user_id'])
if exists:
await message.reply(f"Пользователь уже был заблокирован ранее.") # Выполняем блокировку
logger.info(f"Пользователь: {user_data['user_id']} был заблокирован ранее)") await admin_service.ban_user(
await state.set_state('ADMIN') user_id=user_data['target_user_id'],
else: username=user_data['target_username'],
BotDB.set_user_blacklist(user_data['user_id'], reason=user_data['ban_reason'],
user_data['user_name'], ban_days=user_data['ban_days']
user_data['message_for_user'], )
user_data['date_to_unban'])
# Экранируем user_name для безопасного использования safe_username = escape_html(user_data['target_username'])
safe_user_name = html.escape(str(user_data['user_name'])) if user_data.get('user_name') else "Неизвестный пользователь" await message.reply(f"Пользователь {safe_username} успешно заблокирован.")
await message.reply(f"Пользователь {safe_user_name} успешно заблокирован.") await return_to_admin_menu(message, state)
logger.info(f"Пользователь: {user_data['user_id']} успешно заблокирован)")
await state.set_state('ADMIN') except UserAlreadyBannedError as e:
markup = get_reply_keyboard_admin() await message.reply(str(e))
await message.answer('Вернулись в меню', reply_markup=markup) await return_to_admin_menu(message, state)
except Exception as e:
await handle_admin_error(message, e, state, "confirm_ban")

View File

@@ -0,0 +1,29 @@
"""Constants for admin handlers"""
from typing import Final, Dict
# Admin button texts
ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
"BAN_LIST": "Бан (Список)",
"BAN_BY_USERNAME": "Бан по нику",
"BAN_BY_ID": "Бан по ID",
"UNBAN_LIST": "Разбан (список)",
"RETURN_TO_BOT": "Вернуться в бота",
"CANCEL": "Отменить"
}
# Admin button to command mapping for metrics
ADMIN_BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Бан (Список)": "admin_ban_list",
"Бан по нику": "admin_ban_by_username",
"Бан по ID": "admin_ban_by_id",
"Разбан (список)": "admin_unban_list",
"Вернуться в бота": "admin_return_to_bot",
"Отменить": "admin_cancel"
}
# Admin commands
ADMIN_COMMANDS: Final[Dict[str, str]] = {
"ADMIN": "admin",
"TEST_METRICS": "test_metrics"
}

View File

@@ -0,0 +1,71 @@
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
username = getattr(event.from_user, 'username', 'Unknown')
logger.info(f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {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()
is_admin_result = await check_access(user_id, bot_db)
logger.info(f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}")
if not is_admin_result:
logger.warning(f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})")
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()]

View File

@@ -0,0 +1,23 @@
class AdminError(Exception):
"""Базовое исключение для административных операций"""
pass
class AdminAccessDeniedError(AdminError):
"""Исключение при отказе в административном доступе"""
pass
class UserNotFoundError(AdminError):
"""Исключение при отсутствии пользователя"""
pass
class InvalidInputError(AdminError):
"""Исключение при некорректном вводе данных"""
pass
class UserAlreadyBannedError(AdminError):
"""Исключение при попытке забанить уже заблокированного пользователя"""
pass

View File

@@ -0,0 +1,272 @@
"""
Обработчики команд для мониторинга rate limiting
"""
from aiogram import Router, types, F
from aiogram.filters import Command, MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary
from helper_bot.utils.rate_limit_metrics import update_rate_limit_gauges, get_rate_limit_metrics_summary
from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors
)
class RateLimitHandlers:
def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db
self.settings = settings
self.router = Router()
self._setup_handlers()
self._setup_middleware()
def _setup_middleware(self):
self.router.message.middleware(DependenciesMiddleware())
def _setup_handlers(self):
# Команда для просмотра статистики rate limiting
self.router.message.register(
self.rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_stats")
)
# Команда для сброса статистики rate limiting
self.router.message.register(
self.reset_rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("reset_ratelimit_stats")
)
# Команда для просмотра ошибок rate limiting
self.router.message.register(
self.rate_limit_errors_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_errors")
)
# Команда для просмотра Prometheus метрик
self.router.message.register(
self.rate_limit_prometheus_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_prometheus")
)
@track_time("rate_limit_stats_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_stats_handler")
async def rate_limit_stats_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Показывает статистику rate limiting"""
try:
# Проверяем права администратора
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Получаем сводку
summary = get_rate_limit_summary()
global_stats = rate_limit_monitor.get_global_stats()
# Формируем сообщение со статистикой
stats_text = (
f"📊 <b>Статистика Rate Limiting</b>\n\n"
f"🔢 <b>Общая статистика:</b>\n"
f"Всего запросов: {summary['total_requests']}\n"
f"• Процент успеха: {summary['success_rate']:.1%}\n"
f"• Процент ошибок: {summary['error_rate']:.1%}\n"
f"• Запросов в минуту: {summary['requests_per_minute']:.1f}\n"
f"• Среднее время ожидания: {summary['average_wait_time']:.2f}с\n"
f"• Активных чатов: {summary['active_chats']}\n"
f"• Ошибок за час: {summary['recent_errors_count']}\n\n"
)
# Добавляем детальную статистику
stats_text += f"🔍 <b>Детальная статистика:</b>\n"
stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n"
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n"
stats_text += f"• Других ошибок: {global_stats.other_errors}\n"
stats_text += f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
# Добавляем топ чатов по запросам
top_chats = rate_limit_monitor.get_top_chats_by_requests(5)
if top_chats:
stats_text += f"📈 <b>Топ-5 чатов по запросам:</b>\n"
for i, (chat_id, chat_stats) in enumerate(top_chats, 1):
stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n"
stats_text += "\n"
# Добавляем чаты с высоким процентом ошибок
high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1)
if high_error_chats:
stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n"
for chat_id, chat_stats in high_error_chats[:3]:
stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n"
await message.answer(stats_text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
await message.answer("Произошла ошибка при получении статистики.")
@track_time("reset_rate_limit_stats_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler")
async def reset_rate_limit_stats_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Сбрасывает статистику rate limiting"""
try:
# Проверяем права администратора
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Сбрасываем статистику
rate_limit_monitor.reset_stats()
await message.answer("✅ Статистика rate limiting сброшена.")
except Exception as e:
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
await message.answer("Произошла ошибка при сбросе статистики.")
@track_time("rate_limit_errors_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_errors_handler")
async def rate_limit_errors_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Показывает недавние ошибки rate limiting"""
try:
# Проверяем права администратора
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Получаем ошибки за последний час
recent_errors = rate_limit_monitor.get_recent_errors(60)
error_summary = rate_limit_monitor.get_error_summary(60)
if not recent_errors:
await message.answer("✅ Ошибок rate limiting за последний час не было.")
return
# Формируем сообщение с ошибками
errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
errors_text += f"📊 <b>Сводка ошибок:</b>\n"
for error_type, count in error_summary.items():
errors_text += f"{error_type}: {count}\n"
errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n"
# Показываем последние 10 ошибок
errors_text += f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
# Если сообщение слишком длинное, разбиваем на части
if len(errors_text) > 4000:
# Отправляем сводку
summary_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
summary_text += f"📊 <b>Сводка ошибок:</b>\n"
for error_type, count in error_summary.items():
summary_text += f"{error_type}: {count}\n"
summary_text += f"\nВсего ошибок: {len(recent_errors)}"
await message.answer(summary_text, parse_mode='HTML')
# Отправляем детали отдельным сообщением
details_text = f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
await message.answer(details_text, parse_mode='HTML')
else:
await message.answer(errors_text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
await message.answer("Произошла ошибка при получении информации об ошибках.")
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
async def rate_limit_prometheus_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Показывает Prometheus метрики rate limiting"""
try:
# Проверяем права администратора
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Обновляем gauge метрики
update_rate_limit_gauges()
# Получаем сводку метрик
metrics_summary = get_rate_limit_metrics_summary()
# Формируем сообщение с метриками
metrics_text = (
f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n"
f"🔢 <b>Основные метрики:</b>\n"
f"• rate_limit_requests_total: {metrics_summary['total_requests']}\n"
f"• rate_limit_success_rate: {metrics_summary['success_rate']:.3f}\n"
f"• rate_limit_error_rate: {metrics_summary['error_rate']:.3f}\n"
f"• rate_limit_requests_per_minute: {metrics_summary['requests_per_minute']:.1f}\n"
f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n"
f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n"
)
# Добавляем детальные метрики
metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
metrics_text += f"• Успешных запросов: {metrics_summary['successful_requests']}\n"
metrics_text += f"• Неудачных запросов: {metrics_summary['failed_requests']}\n"
metrics_text += f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n"
metrics_text += f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
# Добавляем информацию о доступных метриках
metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n"
metrics_text += f"• rate_limit_requests_total - общее количество запросов\n"
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n"
metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n"
metrics_text += f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
metrics_text += f"• rate_limit_active_chats - количество активных чатов\n"
metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n"
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
metrics_text += f"• rate_limit_total_requests - общее количество запросов\n"
metrics_text += f"• rate_limit_total_errors - количество ошибок\n"
metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n"
await message.answer(metrics_text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка при получении Prometheus метрик: {e}")
await message.answer("Произошла ошибка при получении метрик.")

View File

@@ -0,0 +1,175 @@
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
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors
)
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
@track_time("get_last_users", "admin_service")
@track_errors("admin_service", "get_last_users")
async def get_last_users(self) -> List[User]:
"""Получить список последних пользователей"""
try:
users_data = await self.bot_db.get_last_users(30)
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
@track_time("get_banned_users", "admin_service")
@track_errors("admin_service", "get_banned_users")
async def get_banned_users(self) -> List[BannedUser]:
"""Получить список заблокированных пользователей"""
try:
banned_users_data = await self.bot_db.get_banned_users_from_db()
banned_users = []
for user_data in banned_users_data:
user_id, reason, unban_date = user_data
# Получаем username и full_name из таблицы users
username = await self.bot_db.get_username(user_id)
full_name = await self.bot_db.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
banned_users.append(BannedUser(
user_id=user_id,
username=user_name,
reason=reason,
unban_date=unban_date
))
return banned_users
except Exception as e:
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}")
raise
@track_time("get_user_by_username", "admin_service")
@track_errors("admin_service", "get_user_by_username")
async def get_user_by_username(self, username: str) -> Optional[User]:
"""Получить пользователя по username"""
try:
user_id = await self.bot_db.get_user_id_by_username(username)
if not user_id:
return None
full_name = await 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
@track_time("get_user_by_id", "admin_service")
@track_errors("admin_service", "get_user_by_id")
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получить пользователя по ID"""
try:
user_info = await self.bot_db.get_user_by_id(user_id)
if not user_info:
return None
return User(
user_id=user_id,
username=user_info.username or 'Неизвестно',
full_name=user_info.full_name or 'Неизвестно'
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
raise
@track_time("ban_user", "admin_service")
@track_errors("admin_service", "ban_user")
async def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int]) -> None:
"""Заблокировать пользователя"""
try:
# Проверяем, не заблокирован ли уже пользователь
if await 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)
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
await self.bot_db.set_user_blacklist(user_id, None, reason, date_to_unban)
logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней")
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
raise
@track_time("unban_user", "admin_service")
@track_errors("admin_service", "unban_user")
async def unban_user(self, user_id: int) -> None:
"""Разблокировать пользователя"""
try:
await self.bot_db.delete_user_blacklist(user_id)
logger.info(f"Пользователь {user_id} разблокирован")
except Exception as e:
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
raise
@track_time("validate_user_input", "admin_service")
@track_errors("admin_service", "validate_user_input")
async 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 пользователя должен быть числом")
@track_time("get_banned_users_for_display", "admin_service")
@track_errors("admin_service", "get_banned_users_for_display")
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
"""Получить данные заблокированных пользователей для отображения"""
try:
message_text = await get_banned_users_list(page, self.bot_db)
buttons_list = await get_banned_users_buttons(self.bot_db)
return message_text, buttons_list
except Exception as e:
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}")
raise

View File

@@ -0,0 +1,65 @@
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:
"""Универсальная функция для возврата в админ-меню"""
logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}")
await state.set_data({})
await state.set_state("ADMIN")
markup = get_reply_keyboard_admin()
if additional_message:
logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}")
await message.answer(additional_message)
await message.answer('Вернулись в меню', reply_markup=markup)
logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню")
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"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {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"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}")

View File

@@ -1 +1,24 @@
from .callback_handlers import callback_router 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'
]

View File

@@ -1,284 +1,337 @@
import html import html
import traceback import traceback
import time
from datetime import datetime
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.filters import MagicData
from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE
from helper_bot.handlers.voice.services import AudioFileService
from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \ from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \
create_keyboard_for_ban_reason 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 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, \ from .dependency_factory import get_post_publish_service, get_ban_service
get_banned_users_buttons, delete_user_blacklist, send_media_group_to_channel, \ from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
send_video_message, send_video_note_message, send_audio_message, send_voice_message 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 from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time,
track_file_operations
)
callback_router = Router() 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 == CALLBACK_PUBLISH)
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
@callback_router.callback_query( async def post_for_group(
F.data == "publish" call: CallbackQuery,
) settings: MagicData("settings")
async def post_for_group(call: CallbackQuery, state: FSMContext): ):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') 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: try:
# Пересылаем сообщение в канал await publish_service.publish_post(call)
await send_text_message(MAIN_PUBLIC, call.message, text_post) await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
except UserBlockedBotError:
# Получаем из базы автора await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
author_id = BotDB.get_author_id_by_message_id(call.message.message_id) except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при публикации поста: {str(e)}')
# Очищаем предложку и удаляем оттуда пост await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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: except Exception as e:
if e.message != 'Forbidden: bot was blocked by the user': if str(e) == ERROR_BOT_BLOCKED:
await call.bot.send_message(chat_id=IMPORTANT_LOGS, await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") else:
logger.error(f'Ошибка при публикации текста в канал {MAIN_PUBLIC}: {str(e)}') important_logs = settings['Telegram']['important_logs']
await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) await call.bot.send_message(
elif call.message.content_type == 'photo': chat_id=important_logs,
try: text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
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.error(f'Неожиданная ошибка при публикации поста: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
@track_time("decline_post_for_group", "callback_handlers")
@track_errors("callback_handlers", "decline_post_for_group")
async def decline_post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})')
try: try:
if call.message.content_type == 'text' and call.message.text != "^" or call.message.content_type == 'photo' \ await publish_service.decline_post(call)
or call.message.content_type == 'audio' or call.message.content_type == 'voice' \ await call.answer(text=MESSAGE_DECLINED, cache_time=3)
or call.message.content_type == 'video' or call.message.content_type == 'video_note': except UserBlockedBotError:
await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
# Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки logger.error(f'Ошибка при отклонении поста: {str(e)}')
author_id = BotDB.get_author_id_by_message_id(call.message.message_id) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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: except Exception as e:
if e.message != 'Forbidden: bot was blocked by the user': if str(e) == ERROR_BOT_BLOCKED:
await call.bot.send_message(IMPORTANT_LOGS, await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") else:
logger.error(f'Ошибка при удалении сообщения в группе {GROUP_FOR_POST}: {str(e)}') important_logs = settings['Telegram']['important_logs']
await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
@callback_router.callback_query(
F.data.contains('ban')
) )
async def process_ban_user(call: CallbackQuery, state: FSMContext): 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)
@track_time("ban_user_from_post", "callback_handlers")
@track_errors("callback_handlers", "ban_user_from_post")
async def ban_user_from_post(call: CallbackQuery, **kwargs):
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))
@track_time("process_ban_user", "callback_handlers")
@track_errors("callback_handlers", "process_ban_user")
async def process_ban_user(call: CallbackQuery, state: FSMContext, **kwargs):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[4:] user_id = call.data[4:]
logger.info( logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
user_name = BotDB.get_username(user_id=user_id) # Проверяем, что user_id является валидным числом
if user_name: try:
await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, user_id_int = int(user_id)
date_to_unban=None) except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
return
try:
user_name = await ban_service.ban_user(str(user_id_int), "")
await state.update_data(user_id=user_id_int, user_name=user_name, message_for_user=None, date_to_unban=None)
markup = create_keyboard_for_ban_reason() markup = create_keyboard_for_ban_reason()
# Экранируем потенциально проблемные символы
user_name_escaped = html.escape(str(user_name)) user_name_escaped = html.escape(str(user_name))
full_name_escaped = html.escape(str(call.message.from_user.full_name)) full_name_escaped = html.escape(str(call.message.from_user.full_name))
await call.message.answer( await call.message.answer(
text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", text=f"<b>Выбран пользователь:\nid:</b> {user_id_int}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup) reply_markup=markup
)
await state.set_state('BAN_2') await state.set_state('BAN_2')
else: except UserNotFoundError:
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', markup=markup) await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN') await state.set_state('ADMIN')
@callback_router.callback_query( @callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
F.data.contains('unlock') @track_time("process_unlock_user", "callback_handlers")
) @track_errors("callback_handlers", "process_unlock_user")
async def process_unlock_user(call: CallbackQuery): async def process_unlock_user(call: CallbackQuery, **kwargs):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[7:] user_id = call.data[7:]
user_name = BotDB.get_username(user_id=user_id)
delete_user_blacklist(user_id, BotDB) # Проверяем, что user_id является валидным числом
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") try:
username = BotDB.get_username(user_id) user_id_int = int(user_id)
await call.answer(f'Пользователь разблокирован {username}', show_alert=True) except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
return
try:
username = await ban_service.unlock_user(str(user_id_int))
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( @callback_router.callback_query(F.data == CALLBACK_RETURN)
F.data == 'return' @track_time("return_to_main_menu", "callback_handlers")
) @track_errors("callback_handlers", "return_to_main_menu")
async def return_to_main_menu(call: CallbackQuery): async def return_to_main_menu(call: CallbackQuery, **kwargs):
await call.message.delete() await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
reply_markup=markup)
@callback_router.callback_query( @callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
F.data.contains('page') @track_time("change_page", "callback_handlers")
) @track_errors("callback_handlers", "change_page")
async def change_page(call: CallbackQuery): async def change_page(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
**kwargs
):
try:
page_number = int(call.data[5:]) page_number = int(call.data[5:])
except ValueError:
logger.error(f"Некорректный номер страницы в callback: {call.data}")
await call.answer(text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3)
return
logger.info(f"Переход на страницу {page_number}") logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту': if call.message.text == 'Список пользователей которые последними обращались к боту':
list_users = BotDB.get_last_users_from_db() list_users = await bot_db.get_last_users(30)
# TODO: Здесь где-то надо добавить обработку ошибки IndexError: list index out of range keyboard = create_keyboard_with_pagination(page_number, len(list_users), list_users, 'ban')
keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, await call.bot.edit_message_reply_markup(
'ban') chat_id=call.message.chat.id,
message_id=call.message.message_id,
await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, reply_markup=keyboard
reply_markup=keyboard) )
else: else:
# Готовим сообщения message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db)
message_user = get_banned_users_list(int(page_number) * 7 - 7, BotDB) await call.bot.edit_message_text(
await call.bot.edit_message_text(chat_id=call.message.chat.id, message_id=call.message.message_id, chat_id=call.message.chat.id,
text=message_user) message_id=call.message.message_id,
text=message_user
)
# Готовим клавиатуру buttons = await get_banned_users_buttons(bot_db)
buttons = get_banned_users_buttons(BotDB) keyboard = create_keyboard_with_pagination(page_number, len(buttons), buttons, 'unlock')
keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock') await call.bot.edit_message_reply_markup(
await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, chat_id=call.message.chat.id,
reply_markup=keyboard) message_id=call.message.message_id,
reply_markup=keyboard
)
@callback_router.callback_query(F.data == CALLBACK_SAVE)
@track_time("save_voice_message", "callback_handlers")
@track_errors("callback_handlers", "save_voice_message")
@track_file_operations("voice")
@db_query_time("save_voice_message", "audio_moderate", "mixed")
async def save_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
):
try:
logger.info(f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}")
# Создаем сервис для работы с аудио файлами
audio_service = AudioFileService(bot_db)
# Получаем ID пользователя из базы
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_id)
logger.info(f"Получен user_id: {user_id}")
# Генерируем имя файла
file_name = await audio_service.generate_file_name(user_id)
logger.info(f"Сгенерировано имя файла: {file_name}")
# Собираем инфо о сообщении
time_UTC = int(time.time())
date_added = datetime.fromtimestamp(time_UTC)
# Получаем file_id из voice сообщения
file_id = call.message.voice.file_id if call.message.voice else ""
logger.info(f"Получен file_id: {file_id}")
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск
logger.info("Начинаем скачивание и сохранение файла на диск...")
await audio_service.download_and_save_audio(call.bot, call.message, file_name)
logger.info("Файл успешно скачан и сохранен на диск")
# Только после успешного сохранения файла - сохраняем в базу данных
logger.info("Начинаем сохранение информации в базу данных...")
await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
logger.info("Информация успешно сохранена в базу данных")
# Удаляем сообщение из предложки
logger.info("Удаляем сообщение из предложки...")
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
)
logger.info("Сообщение удалено из предложки")
# Удаляем запись из таблицы audio_moderate
logger.info("Удаляем запись из таблицы audio_moderate...")
await bot_db.delete_audio_moderate_record(call.message.message_id)
logger.info("Запись удалена из таблицы audio_moderate")
await call.answer(text='Сохранено!', cache_time=3)
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
except Exception as e:
logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Дополнительная информация для диагностики
try:
if 'call' in locals() and call.message:
logger.error(f"Message ID: {call.message.message_id}")
logger.error(f"User ID: {user_id if 'user_id' in locals() else 'не определен'}")
logger.error(f"File name: {file_name if 'file_name' in locals() else 'не определен'}")
except:
pass
await call.answer(text='Ошибка при сохранении!', cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DELETE)
@track_time("delete_voice_message", "callback_handlers")
@track_errors("callback_handlers", "delete_voice_message")
@db_query_time("delete_voice_message", "audio_moderate", "delete")
async def delete_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
):
try:
# Удаляем сообщение из предложки
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
)
# Удаляем запись из таблицы audio_moderate
await bot_db.delete_audio_moderate_record(call.message.message_id)
await call.answer(text='Удалено!', cache_time=3)
except Exception as e:
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text='Ошибка при удалении!', cache_time=3)

View File

@@ -0,0 +1,41 @@
from typing import Final, Dict
# 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"
# Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"publish": "publish",
"decline": "decline",
"ban": "ban",
"unlock": "unlock",
"return": "return",
"page": "page"
}

View File

@@ -0,0 +1,25 @@
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()
db = bdf.get_db()
settings = bdf.settings
return PostPublishService(None, db, settings)
def get_ban_service() -> BanService:
"""Фабрика для BanService"""
bdf = get_global_instance()
db = bdf.get_db()
settings = bdf.settings
return BanService(None, db, settings)

View File

@@ -0,0 +1,23 @@
class UserBlockedBotError(Exception):
"""Исключение, возникающее когда пользователь заблокировал бота"""
pass
class PostNotFoundError(Exception):
"""Исключение, возникающее когда пост не найден в базе данных"""
pass
class UserNotFoundError(Exception):
"""Исключение, возникающее когда пользователь не найден в базе данных"""
pass
class PublishError(Exception):
"""Общее исключение для ошибок публикации"""
pass
class BanError(Exception):
"""Исключение для ошибок бана/разбана пользователей"""
pass

View File

@@ -0,0 +1,395 @@
from datetime import datetime, timedelta
import html
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
# Local imports - metrics
from helper_bot.utils.metrics import (
track_media_processing,
track_time,
track_errors,
db_query_time
)
class PostPublishService:
def __init__(self, bot: Bot, db, settings: Dict[str, Any]):
# bot может быть None - в этом случае используем бота из контекста сообщения
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']
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
if self.bot:
return self.bot
return message.bot
@track_time("publish_post", "post_publish_service")
@track_errors("post_publish_service", "publish_post")
async def publish_post(self, call: CallbackQuery) -> None:
"""Основной метод публикации поста"""
# Проверяем, является ли сообщение частью медиагруппы
if call.message.media_group_id:
await self._publish_media_group(call)
return
content_type = call.message.content_type
if content_type == CONTENT_TYPE_TEXT:
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)
else:
raise PublishError(f"Неподдерживаемый тип контента: {content_type}")
@track_time("_publish_text_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_text_post")
async def _publish_text_post(self, call: CallbackQuery) -> None:
"""Публикация текстового поста"""
text_post = html.escape(str(call.message.text))
author_id = await 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}.')
@track_time("_publish_photo_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_photo_post")
async def _publish_photo_post(self, call: CallbackQuery) -> None:
"""Публикация поста с фото"""
text_post_with_photo = html.escape(str(call.message.caption))
author_id = await 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}.')
@track_time("_publish_video_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_post")
async def _publish_video_post(self, call: CallbackQuery) -> None:
"""Публикация поста с видео"""
text_post_with_photo = html.escape(str(call.message.caption))
author_id = await 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}.')
@track_time("_publish_video_note_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_note_post")
async def _publish_video_note_post(self, call: CallbackQuery) -> None:
"""Публикация поста с кружком"""
author_id = await 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}.')
@track_time("_publish_audio_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_audio_post")
async def _publish_audio_post(self, call: CallbackQuery) -> None:
"""Публикация поста с аудио"""
text_post_with_photo = html.escape(str(call.message.caption))
author_id = await 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}.')
@track_time("_publish_voice_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_voice_post")
async def _publish_voice_post(self, call: CallbackQuery) -> None:
"""Публикация поста с войсом"""
author_id = await 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}.')
@track_time("_publish_media_group", "post_publish_service")
@track_errors("post_publish_service", "_publish_media_group")
@track_media_processing("media_group")
async def _publish_media_group(self, call: CallbackQuery) -> None:
"""Публикация медиагруппы"""
logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
try:
# call.message.message_id - это ID helper сообщения
helper_message_id = call.message.message_id
# Получаем контент медиагруппы по helper_message_id
logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}")
post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
if not post_content:
logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}")
raise PublishError("Контент медиагруппы не найден в базе данных")
# Получаем текст поста по helper_message_id
logger.debug(f"Получаю текст поста для helper_message_id: {helper_message_id}")
pre_text = await self.db.get_post_text_by_helper_id(helper_message_id)
post_text = html.escape(str(pre_text)) if pre_text else ""
logger.debug(f"Текст поста получен: {'пустой' if not post_text else f'длина: {len(post_text)} символов'}")
# Получаем ID автора по helper_message_id
logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}")
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
if not author_id:
logger.error(f"Автор не найден для медиагруппы {helper_message_id}")
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
logger.debug(f"ID автора получен: {author_id}")
# Отправляем медиагруппу в канал
logger.info(f"Отправляю медиагруппу в канал {self.main_public}")
await send_media_group_to_channel(
bot=self._get_bot(call.message),
chat_id=self.main_public,
post_content=post_content,
post_text=post_text
)
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
await self._delete_media_group_and_notify_author(call, author_id)
logger.info(f'Медиагруппа опубликована в канале {self.main_public}.')
except Exception as e:
logger.error(f"Ошибка при публикации медиагруппы: {e}")
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
@track_time("decline_post", "post_publish_service")
@track_errors("post_publish_service", "decline_post")
async def decline_post(self, call: CallbackQuery) -> None:
"""Отклонение поста"""
logger.info(f"Начинаю отклонение поста. Message ID: {call.message.message_id}, Content type: {call.message.content_type}")
# Проверяем, является ли сообщение частью медиагруппы (осознанный костыль, т.к. сообщение к которому прикреплен коллбек без медиагруппы)
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
logger.debug("Сообщение является частью медиагруппы, вызываю _decline_media_group")
await self._decline_media_group(call)
return
content_type = call.message.content_type
if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
logger.debug(f"Отклоняю одиночный пост типа: {content_type}")
await self._decline_single_post(call)
else:
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
@track_time("_decline_single_post", "post_publish_service")
@track_errors("post_publish_service", "_decline_single_post")
async def _decline_single_post(self, call: CallbackQuery) -> None:
"""Отклонение одиночного поста"""
logger.debug(f"Отклоняю одиночный пост. Message ID: {call.message.message_id}")
author_id = await self._get_author_id(call.message.message_id)
logger.debug(f"ID автора получен: {author_id}")
logger.debug(f"Удаляю сообщение из группы {self.group_for_posts}")
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
try:
logger.debug(f"Отправляю уведомление об отклонении автору {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
raise
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
@track_time("_decline_media_group", "post_publish_service")
@track_errors("post_publish_service", "_decline_media_group")
@track_media_processing("media_group")
async def _decline_media_group(self, call: CallbackQuery) -> None:
"""Отклонение медиагруппы"""
logger.debug(f"Отклоняю медиагруппу. Helper message ID: {call.message.message_id}")
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
message_ids = post_ids.copy()
message_ids.append(call.message.message_id)
logger.debug(f"Получены ID сообщений для удаления: {message_ids}")
author_id = await self._get_author_id_for_media_group(call.message.message_id)
logger.debug(f"ID автора медиагруппы получен: {author_id}")
logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}")
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
try:
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}")
raise
@track_time("_get_author_id", "post_publish_service")
@track_errors("post_publish_service", "_get_author_id")
async def _get_author_id(self, message_id: int) -> int:
"""Получение ID автора по ID сообщения"""
author_id = await self.db.get_author_id_by_message_id(message_id)
if not author_id:
raise PostNotFoundError(f"Автор не найден для сообщения {message_id}")
return author_id
@track_time("_get_author_id_for_media_group", "post_publish_service")
@track_errors("post_publish_service", "_get_author_id_for_media_group")
async def _get_author_id_for_media_group(self, message_id: int) -> int:
"""Получение ID автора для медиагруппы"""
# Сначала пытаемся найти автора по helper_message_id
author_id = await self.db.get_author_id_by_helper_message_id(message_id)
if author_id:
return author_id
# Если не найден, ищем по основному message_id медиагруппы
# Для этого нужно найти связанные сообщения медиагруппы
try:
# Получаем все ID сообщений медиагруппы
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(message_id)
if post_ids:
# Берем первый ID (основное сообщение медиагруппы)
main_message_id = post_ids[0]
author_id = await self.db.get_author_id_by_message_id(main_message_id)
if author_id:
return author_id
except Exception as e:
logger.warning(f"Не удалось найти автора через связанные сообщения: {e}")
# Если все способы не сработали, ищем напрямую
author_id = await self.db.get_author_id_by_message_id(message_id)
if not author_id:
raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}")
return author_id
@track_time("_delete_post_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_post_and_notify_author")
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
"""Удаление поста и уведомление автора"""
await self._get_bot(call.message).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
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
@track_media_processing("media_group")
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
"""Удаление медиагруппы и уведомление автора"""
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
#message_ids = post_ids.copy()
post_ids.append(call.message.message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_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']
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
if self.bot:
return self.bot
return message.bot
@track_time("ban_user_from_post", "ban_service")
@track_errors("ban_service", "ban_user_from_post")
@db_query_time("ban_user_from_post", "users", "mixed")
async def ban_user_from_post(self, call: CallbackQuery) -> None:
"""Бан пользователя за спам"""
author_id = await self.db.get_author_id_by_message_id(call.message.message_id)
if not author_id:
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")
current_date = datetime.now()
date_to_unban = int((current_date + timedelta(days=7)).timestamp())
await self.db.set_user_blacklist(
user_id=author_id,
user_name=None,
message_for_user="Спам",
date_to_unban=date_to_unban
)
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
date_str = (current_date + timedelta(days=7)).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}")
@track_time("ban_user", "ban_service")
@track_errors("ban_service", "ban_user")
async def ban_user(self, user_id: str, user_name: str) -> str:
"""Бан пользователя по ID"""
user_name = await self.db.get_username(int(user_id))
if not user_name:
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
return user_name
@track_time("unlock_user", "ban_service")
@track_errors("ban_service", "unlock_user")
@db_query_time("unlock_user", "users", "delete")
async def unlock_user(self, user_id: str) -> str:
"""Разблокировка пользователя"""
user_name = await self.db.get_username(int(user_id))
if not user_name:
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
await delete_user_blacklist(int(user_id), self.db)
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}")
return user_name

View File

@@ -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'
]

View File

@@ -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": "Не могу найти кому ответить в базе, проебали сообщение."
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,49 +1,117 @@
"""Main group handlers module for Telegram bot"""
# Third-party imports
from aiogram import Router, types from aiogram import Router, types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
# Local imports - filters
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter 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 # Local imports - modular components
from helper_bot.utils.helper_func import send_text_message 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 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: AsyncBotDB, 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
@track_errors("group_handlers", "handle_message")
@track_time("handle_message", "group_handlers")
async def handle_message(self, message: types.Message, state: FSMContext, **kwargs):
"""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 = await 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: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers:
"""Create group handlers instance with dependencies"""
return GroupHandlers(db, keyboard_markup)
# Legacy router for backward compatibility
group_router = Router() group_router = Router()
# 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() bdf = get_global_instance()
GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] #TODO: поменять архитектуру и подключить правильный BotDB
GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] db = bdf.get_db()
MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] keyboard_markup = get_reply_keyboard_leave_chat()
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() handlers = create_group_handlers(db, keyboard_markup)
group_router = handlers.router
# Initialize legacy router
@group_router.message( init_legacy_router()
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)}')

View File

@@ -0,0 +1,77 @@
"""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 (
track_time,
track_errors,
db_query_time
)
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None): ...
class AdminReplyService:
"""Service for admin reply operations"""
def __init__(self, db: DatabaseProtocol) -> None:
self.db = db
@track_time("get_user_id_for_reply", "admin_reply_service")
@track_errors("admin_reply_service", "get_user_id_for_reply")
@db_query_time("get_user_id_for_reply", "users", "select")
async 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 = await 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
@track_time("send_reply_to_user", "admin_reply_service")
@track_errors("admin_reply_service", "send_reply_to_user")
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"}'
)

View File

@@ -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'
]

View File

@@ -0,0 +1,43 @@
"""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": "📩Связаться с админами",
"VOICE_BOT": "🎤Голосовой бот"
}
# Button to command mapping for metrics
BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"📢Предложить свой пост": "suggest_post",
"👋🏼Сказать пока!": "say_goodbye",
"Выйти из чата": "leave_chat",
"Вернуться в бота": "return_to_bot",
"🤪Хочу стикеры": "want_stickers",
"📩Связаться с админами": "connect_admin",
"🎤Голосовой бот": "voice_bot"
}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. '
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'
'Мы добавим его к обработке если необходимо'
),
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk"
}

View File

@@ -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

View File

@@ -1,503 +1,262 @@
import random """Main private handlers module for Telegram bot"""
import traceback
import asyncio
import html
from datetime import datetime
from pathlib import Path
# Standard library imports
import asyncio
from datetime import datetime
# Third-party imports
from aiogram import types, Router, F from aiogram import types, Router, F
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
# Local imports - filters and middlewares
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter 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.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware 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 import messages
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.helper_func import (
from helper_bot.utils.helper_func import get_first_name, get_text_message, send_text_message, send_photo_message, \ get_first_name,
send_media_group_message_to_private_chat, prepare_media_group_from_middlewares, send_video_message, \ update_user_info,
send_video_note_message, send_audio_message, send_voice_message, add_in_db_media, \ check_user_emoji
check_user_emoji, check_username_and_full_name )
from logs.custom_logger import logger
private_router = Router() # Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time
)
private_router.message.middleware(AlbumMiddleware()) # Local imports - modular components
private_router.message.middleware(BlacklistMiddleware()) from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
from .services import BotSettings, UserService, PostService, StickerService
bdf = get_global_instance() from .decorators import error_handler
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()
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep) # Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
sleep = asyncio.sleep sleep = asyncio.sleep
@private_router.message(
ChatTypeFilter(chat_type=["private"]), class PrivateHandlers:
Command("emoji") """Main handler class for private messages"""
)
async def handle_emoji_message(message: types.Message, state: FSMContext): def __init__(self, db: AsyncBotDB, settings: BotSettings):
await message.forward(chat_id=GROUP_FOR_LOGS) self.db = db
user_emoji = check_user_emoji(message) self.settings = settings
await state.set_state("START") 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
@track_errors("private_handlers", "handle_emoji_message")
@track_time("handle_emoji_message", "private_handlers")
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 = await check_user_emoji(message)
await state.set_state(FSM_STATES["START"])
if user_emoji is not None: if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
@error_handler
@private_router.message( @track_errors("private_handlers", "handle_restart_message")
ChatTypeFilter(chat_type=["private"]), @track_time("handle_restart_message", "private_handlers")
Command("restart") async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs):
) """Handle restart command"""
async def handle_restart_message(message: types.Message, state: FSMContext): markup = await get_reply_keyboard(self.db, message.from_user.id)
try: await self.user_service.log_user_message(message)
markup = get_reply_keyboard(BotDB, message.from_user.id) await state.set_state(FSM_STATES["START"])
await message.forward(chat_id=GROUP_FOR_LOGS)
await state.set_state("START")
await update_user_info('love', message) await update_user_info('love', message)
check_user_emoji(message) await check_user_emoji(message)
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML') 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()}")
@error_handler
@track_errors("private_handlers", "handle_start_message")
@track_time("handle_start_message", "private_handlers")
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"])
@private_router.message( # Send sticker with metrics
ChatTypeFilter(chat_type=["private"]), await self.sticker_service.send_random_hello_sticker(message)
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
# Проверяем наличие username для логирования # Send welcome message with metrics
if not username: markup = await get_reply_keyboard(self.db, message.from_user.id)
# Экранируем 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"
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)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE') hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML') 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()}")
@error_handler
@track_errors("private_handlers", "suggest_post")
@track_time("suggest_post", "private_handlers")
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"])
@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"
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() markup = types.ReplyKeyboardRemove()
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS') suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS')
await message.answer(suggest_news) await message.answer(suggest_news, reply_markup=markup)
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()}")
@error_handler
@track_errors("private_handlers", "end_message")
@track_time("end_message", "private_handlers")
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)
@private_router.message( # Send sticker
ChatTypeFilter(chat_type=["private"]), await self.sticker_service.send_random_goodbye_sticker(message)
F.text == '👋🏼Сказать пока!'
) # Send goodbye message
@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:
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE') bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE')
await message.answer(bye_message, reply_markup=markup) await message.answer(bye_message, reply_markup=markup)
await state.set_state("START") await state.set_state(FSM_STATES["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()}")
@error_handler
@track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers")
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.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message)
await self.post_service.process_post(message, album)
@private_router.message( # Send success message and return to start state
StateFilter("SUGGEST"), markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
ChatTypeFilter(chat_type=["private"]), 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
@track_errors("private_handlers", "stickers")
@track_time("stickers", "private_handlers")
@db_query_time("stickers", "stickers", "update")
async def stickers(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle stickers request"""
# User service operations with metrics
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.db.update_stickers_info(message.from_user.id)
await self.user_service.log_user_message(message)
await message.answer(
text=ERROR_MESSAGES["STICKERS_LINK"],
reply_markup=markup
) )
async def suggest_router(message: types.Message, state: FSMContext, album: list = None): await state.set_state(FSM_STATES["START"])
# Экранируем 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()
# Отправляем сообщение в приватный канал @error_handler
sent_message_id = await send_text_message(GROUP_FOR_POST, message, post_text, markup) @track_errors("private_handlers", "connect_with_admin")
@track_time("connect_with_admin", "private_handlers")
# Записываем в базу пост async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs):
BotDB.add_post_in_db(sent_message_id, message.text, message.from_user.id) """Handle connect with admin button"""
# User service operations with metrics
# Отправляем юзеру ответ, что сообщение отравлено и возвращаем его в меню await self.user_service.update_user_activity(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()}")
@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}")
@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') admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN')
await message.answer(admin_message, parse_mode="html") await message.answer(admin_message, parse_mode="html")
await message.forward(chat_id=GROUP_FOR_LOGS) await self.user_service.log_user_message(message)
await state.set_state("PRE_CHAT") await state.set_state(FSM_STATES["PRE_CHAT"])
@error_handler
@track_errors("private_handlers", "resend_message_in_group_for_message")
@track_time("resend_message_in_group_for_message", "private_handlers")
@db_query_time("resend_message_in_group_for_message", "messages", "insert")
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)
@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() current_date = datetime.now()
date = current_date.strftime("%Y-%m-%d %H:%M:%S") date = int(current_date.timestamp())
BotDB.update_date_for_user(date, user_id) await self.db.add_message(message.text, message.from_user.id, message.message_id + 1, date)
# Экранируем 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') question = messages.get_message(get_first_name(message), 'QUESTION')
user_state = await state.get_state() user_state = await state.get_state()
if user_state == "PRE_CHAT":
markup = get_reply_keyboard(BotDB, message.from_user.id) if user_state == FSM_STATES["PRE_CHAT"]:
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(question, reply_markup=markup) await message.answer(question, reply_markup=markup)
await state.set_state("START") await state.set_state(FSM_STATES["START"])
elif user_state == "CHAT": elif user_state == FSM_STATES["CHAT"]:
markup = get_reply_keyboard_leave_chat() markup = get_reply_keyboard_leave_chat()
await message.answer(question, reply_markup=markup) await message.answer(question, reply_markup=markup)
# Factory function to create handlers with dependencies
def create_private_handlers(db: AsyncBotDB, settings: BotSettings) -> PrivateHandlers:
"""Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings)
# 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
# Initialize legacy router
init_legacy_router()

View File

@@ -0,0 +1,394 @@
"""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
from database.models import TelegramPost, User
# 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 (
track_time,
track_errors,
db_query_time,
track_media_processing,
track_file_operations
)
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
async def user_exists(self, user_id: int) -> bool: ...
async def add_user(self, user: User) -> None: ...
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None) -> None: ...
async def update_user_date(self, user_id: int) -> None: ...
async def add_post(self, post: TelegramPost) -> None: ...
async def update_stickers_info(self, user_id: int) -> None: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None) -> None: ...
async def update_helper_message(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"""
await self.db.update_user_date(user_id)
@track_time("ensure_user_exists", "user_service")
@track_errors("user_service", "ensure_user_exists")
@db_query_time("ensure_user_exists", "users", "insert")
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
# Create User object with current timestamp
current_timestamp = int(datetime.now().timestamp())
user = User(
user_id=user_id,
first_name=first_name,
full_name=full_name,
username=username,
is_bot=is_bot,
language_code=language_code,
emoji="",
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
)
# Пытаемся создать пользователя (если уже существует - игнорируем)
# Это устраняет race condition и упрощает логику
await self.db.add_user(user)
# Проверяем, нужно ли обновить информацию о существующем пользователе
is_need_update = await check_username_and_full_name(user_id, username, full_name, self.db)
if is_need_update:
await self.db.update_user_info(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=self.settings.group_for_logs,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}')
await self.db.update_user_date(user_id)
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")
@db_query_time("handle_text_post", "posts", "insert")
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)
post = TelegramPost(
message_id=sent_message_id,
text=message.text,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
@track_time("handle_photo_post", "post_service")
@track_errors("post_service", "handle_photo_post")
@db_query_time("handle_photo_post", "posts", "insert")
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
)
post = TelegramPost(
message_id=sent_message.message_id,
text=sent_message.caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db)
if not success:
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post")
@db_query_time("handle_video_post", "posts", "insert")
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
)
post = TelegramPost(
message_id=sent_message.message_id,
text=sent_message.caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db)
if not success:
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post")
@db_query_time("handle_video_note_post", "posts", "insert")
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
)
post = TelegramPost(
message_id=sent_message.message_id,
text=sent_message.caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db)
if not success:
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post")
@db_query_time("handle_audio_post", "posts", "insert")
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
)
post = TelegramPost(
message_id=sent_message.message_id,
text=sent_message.caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db)
if not success:
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post")
@db_query_time("handle_voice_post", "posts", "insert")
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
)
post = TelegramPost(
message_id=sent_message.message_id,
text=sent_message.caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db)
if not success:
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post")
@db_query_time("handle_media_group_post", "posts", "insert")
@track_media_processing("media_group")
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)
# Создаем основной пост для медиагруппы
main_post = TelegramPost(
message_id=message.message_id, # ID основного сообщения медиагруппы
text=post_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(main_post)
# Отправляем медиагруппу в группу для модерации
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, main_post.message_id
)
await asyncio.sleep(0.2)
# Создаем helper сообщение с кнопками
markup = get_reply_keyboard_for_post()
help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА")
# Создаем helper пост и связываем его с основным
helper_post = TelegramPost(
message_id=help_message_id, # ID helper сообщения
text="^", # Специальный маркер для медиагруппы
author_id=message.from_user.id,
helper_text_message_id=main_post.message_id, # Ссылка на основной пост
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(helper_post)
# Обновляем основной пост, чтобы он ссылался на helper
await self.db.update_helper_message(
message_id=main_post.message_id,
helper_message_id=help_message_id
)
@track_time("process_post", "post_service")
@track_errors("post_service", "process_post")
@track_media_processing("media_group")
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)
# TODO: Бесит меня этот функционал
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")
@track_file_operations("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")
@track_file_operations("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)

View File

@@ -0,0 +1,3 @@
from .voice_handler import VoiceHandlers
__all__ = ["VoiceHandlers"]

View File

@@ -0,0 +1,191 @@
"""
Утилиты для очистки и диагностики проблем с голосовыми файлами
"""
import os
import asyncio
from pathlib import Path
from typing import List, Tuple
from logs.custom_logger import logger
from helper_bot.handlers.voice.constants import VOICE_USERS_DIR
class VoiceFileCleanupUtils:
"""Утилиты для очистки и диагностики голосовых файлов"""
def __init__(self, bot_db):
self.bot_db = bot_db
async def find_orphaned_db_records(self) -> List[Tuple[str, int]]:
"""Найти записи в БД, для которых нет соответствующих файлов"""
try:
# Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records()
orphaned_records = []
for record in all_audio_records:
file_name = record.get('file_name', '')
user_id = record.get('author_id', 0)
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
if not os.path.exists(file_path):
orphaned_records.append((file_name, user_id))
logger.warning(f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})")
logger.info(f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов")
return orphaned_records
except Exception as e:
logger.error(f"Ошибка при поиске orphaned записей: {e}")
return []
async def find_orphaned_files(self) -> List[str]:
"""Найти файлы на диске, для которых нет записей в БД"""
try:
if not os.path.exists(VOICE_USERS_DIR):
logger.warning(f"Директория {VOICE_USERS_DIR} не существует")
return []
# Получаем все файлы .ogg в директории
ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
orphaned_files = []
# Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records()
db_file_names = {record.get('file_name', '') for record in all_audio_records}
for file_path in ogg_files:
file_name = file_path.stem # Имя файла без расширения
if file_name not in db_file_names:
orphaned_files.append(str(file_path))
logger.warning(f"Найден файл без записи в БД: {file_path}")
logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД")
return orphaned_files
except Exception as e:
logger.error(f"Ошибка при поиске orphaned файлов: {e}")
return []
async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int:
"""Удалить записи в БД, для которых нет файлов"""
try:
orphaned_records = await self.find_orphaned_db_records()
if not orphaned_records:
logger.info("Нет orphaned записей для удаления")
return 0
if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления")
for file_name, user_id in orphaned_records:
logger.info(f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})")
return len(orphaned_records)
# Удаляем записи
deleted_count = 0
for file_name, user_id in orphaned_records:
try:
await self.bot_db.delete_audio_record_by_file_name(file_name)
deleted_count += 1
logger.info(f"Удалена запись в БД: {file_name} (user_id: {user_id})")
except Exception as e:
logger.error(f"Ошибка при удалении записи {file_name}: {e}")
logger.info(f"Удалено {deleted_count} orphaned записей из БД")
return deleted_count
except Exception as e:
logger.error(f"Ошибка при очистке orphaned записей: {e}")
return 0
async def cleanup_orphaned_files(self, dry_run: bool = True) -> int:
"""Удалить файлы на диске, для которых нет записей в БД"""
try:
orphaned_files = await self.find_orphaned_files()
if not orphaned_files:
logger.info("Нет orphaned файлов для удаления")
return 0
if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления")
for file_path in orphaned_files:
logger.info(f"DRY RUN: Будет удален файл: {file_path}")
return len(orphaned_files)
# Удаляем файлы
deleted_count = 0
for file_path in orphaned_files:
try:
os.remove(file_path)
deleted_count += 1
logger.info(f"Удален файл: {file_path}")
except Exception as e:
logger.error(f"Ошибка при удалении файла {file_path}: {e}")
logger.info(f"Удалено {deleted_count} orphaned файлов")
return deleted_count
except Exception as e:
logger.error(f"Ошибка при очистке orphaned файлов: {e}")
return 0
async def get_disk_usage_stats(self) -> dict:
"""Получить статистику использования диска"""
try:
if not os.path.exists(VOICE_USERS_DIR):
return {"error": f"Директория {VOICE_USERS_DIR} не существует"}
total_size = 0
file_count = 0
for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"):
if file_path.is_file():
total_size += file_path.stat().st_size
file_count += 1
return {
"total_files": file_count,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": VOICE_USERS_DIR
}
except Exception as e:
logger.error(f"Ошибка при получении статистики диска: {e}")
return {"error": str(e)}
async def run_full_diagnostic(self) -> dict:
"""Запустить полную диагностику"""
try:
logger.info("Запуск полной диагностики голосовых файлов...")
# Статистика диска
disk_stats = await self.get_disk_usage_stats()
# Orphaned записи в БД
orphaned_db_records = await self.find_orphaned_db_records()
# Orphaned файлы
orphaned_files = await self.find_orphaned_files()
# Количество записей в БД
all_audio_records = await self.bot_db.get_all_audio_records()
db_records_count = len(all_audio_records)
diagnostic_result = {
"disk_stats": disk_stats,
"db_records_count": db_records_count,
"orphaned_db_records_count": len(orphaned_db_records),
"orphaned_files_count": len(orphaned_files),
"orphaned_db_records": orphaned_db_records[:10], # Первые 10 для примера
"orphaned_files": orphaned_files[:10], # Первые 10 для примера
"status": "healthy" if len(orphaned_db_records) == 0 and len(orphaned_files) == 0 else "issues_found"
}
logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}")
return diagnostic_result
except Exception as e:
logger.error(f"Ошибка при диагностике: {e}")
return {"error": str(e)}

View File

@@ -0,0 +1,59 @@
from typing import Final, Dict
# Voice bot constants
VOICE_BOT_NAME = "voice"
# States
STATE_START = "START"
STATE_STANDUP_WRITE = "STANDUP_WRITE"
# Commands
CMD_START = "start"
CMD_HELP = "help"
CMD_RESTART = "restart"
CMD_EMOJI = "emoji"
CMD_REFRESH = "refresh"
# Command to command mapping for metrics
COMMAND_MAPPING: Final[Dict[str, str]] = {
"start": "voice_start",
"help": "voice_help",
"restart": "voice_restart",
"emoji": "voice_emoji",
"refresh": "voice_refresh"
}
# Button texts
BTN_SPEAK = "🎤Высказаться"
BTN_LISTEN = "🎧Послушать"
# Button to command mapping for metrics
BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"🎤Высказаться": "voice_speak",
"🎧Послушать": "voice_listen",
"Отменить": "voice_cancel",
"🔄Сбросить прослушивания": "voice_refresh_listen",
"😊Узнать эмодзи": "voice_emoji"
}
# Callback data
CALLBACK_SAVE = "save"
CALLBACK_DELETE = "delete"
# Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"save": "voice_save",
"delete": "voice_delete"
}
# File paths
VOICE_USERS_DIR = "voice_users"
STICK_DIR = "Stick"
STICK_PATTERN = "Hello_*"
# Time delays
STICKER_DELAY = 0.3
MESSAGE_DELAY_1 = 1.0
MESSAGE_DELAY_2 = 1.5
MESSAGE_DELAY_3 = 1.3
MESSAGE_DELAY_4 = 0.8

View File

@@ -0,0 +1,23 @@
class VoiceBotError(Exception):
"""Базовое исключение для voice_bot"""
pass
class VoiceMessageError(VoiceBotError):
"""Ошибка при работе с голосовыми сообщениями"""
pass
class AudioProcessingError(VoiceBotError):
"""Ошибка при обработке аудио"""
pass
class DatabaseError(VoiceBotError):
"""Ошибка базы данных"""
pass
class FileOperationError(VoiceBotError):
"""Ошибка при работе с файлами"""
pass

View File

@@ -0,0 +1,445 @@
import random
import asyncio
import traceback
import os
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
from aiogram.types import FSInputFile
from helper_bot.handlers.voice.exceptions import VoiceMessageError, AudioProcessingError, DatabaseError, FileOperationError
from helper_bot.handlers.voice.constants import (
VOICE_USERS_DIR, STICK_DIR, STICK_PATTERN, STICKER_DELAY,
MESSAGE_DELAY_1, MESSAGE_DELAY_2, MESSAGE_DELAY_3, MESSAGE_DELAY_4
)
from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time
)
class VoiceMessage:
"""Модель голосового сообщения"""
def __init__(self, file_name: str, user_id: int, date_added: datetime, file_id: int):
self.file_name = file_name
self.user_id = user_id
self.date_added = date_added
self.file_id = file_id
class VoiceBotService:
"""Сервис для работы с голосовыми сообщениями"""
def __init__(self, bot_db, settings):
self.bot_db = bot_db
self.settings = settings
@track_time("get_welcome_sticker", "voice_bot_service")
@track_errors("voice_bot_service", "get_welcome_sticker")
async def get_welcome_sticker(self) -> Optional[FSInputFile]:
"""Получить случайный приветственный стикер"""
try:
name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN))
if not name_stick_hello:
return None
random_stick_hello = random.choice(name_stick_hello)
random_stick_hello = FSInputFile(path=random_stick_hello)
logger.info(f"Стикер успешно получен. Наименование стикера: {random_stick_hello}")
return random_stick_hello
except Exception as e:
logger.error(f"Ошибка при получении стикера: {e}")
if self.settings['Settings']['logs']:
await self._send_error_to_logs(f'Отправка приветственных стикеров лажает. Ошибка: {e}')
return None
@track_time("send_welcome_messages", "voice_bot_service")
@track_errors("voice_bot_service", "send_welcome_messages")
async def send_welcome_messages(self, message, user_emoji: str):
"""Отправить приветственные сообщения"""
try:
# Отправляем стикер
sticker = await self.get_welcome_sticker()
if sticker:
await message.answer_sticker(sticker)
await asyncio.sleep(STICKER_DELAY)
# Отправляем приветственное сообщение
markup = self._get_main_keyboard()
await message.answer(
text="<b>Привет.</b>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(STICKER_DELAY)
# Отправляем описание
await message.answer(
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_1)
# Отправляем аналогию
await message.answer(
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_2)
# Отправляем правила
await message.answer(
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_3)
# Отправляем информацию об анонимности
await message.answer(
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем предложения
await message.answer(
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию об эмодзи
await message.answer(
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию о помощи
await message.answer(
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем финальное сообщение
await message.answer(
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
except Exception as e:
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}")
@track_time("get_random_audio", "voice_bot_service")
@track_errors("voice_bot_service", "get_random_audio")
async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]:
"""Получить случайное аудио для прослушивания"""
try:
check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
list_audio = list(check_audio)
if not list_audio:
return None
# Получаем случайное аудио
number_element = random.randint(0, len(list_audio) - 1)
audio_for_user = check_audio[number_element]
# Получаем информацию об авторе
user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user)
date_added = await self.bot_db.get_date_by_file_name(audio_for_user)
user_emoji = await self.bot_db.get_user_emoji(user_id_author)
return audio_for_user, date_added, user_emoji
except Exception as e:
logger.error(f"Ошибка при получении случайного аудио: {e}")
raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}")
@track_time("mark_audio_as_listened", "voice_bot_service")
@track_errors("voice_bot_service", "mark_audio_as_listened")
async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None:
"""Пометить аудио как прослушанное"""
try:
await self.bot_db.mark_listened_audio(file_name, user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при пометке аудио как прослушанного: {e}")
raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}")
@track_time("clear_user_listenings", "voice_bot_service")
@track_errors("voice_bot_service", "clear_user_listenings")
@db_query_time("clear_user_listenings", "audio_moderate", "delete")
async def clear_user_listenings(self, user_id: int) -> None:
"""Очистить прослушивания пользователя"""
try:
await self.bot_db.delete_listen_count_for_user(user_id)
except Exception as e:
logger.error(f"Ошибка при очистке прослушиваний: {e}")
raise DatabaseError(f"Не удалось очистить прослушивания: {e}")
@track_time("get_remaining_audio_count", "voice_bot_service")
@track_errors("voice_bot_service", "get_remaining_audio_count")
async def get_remaining_audio_count(self, user_id: int) -> int:
"""Получить количество оставшихся непрослушанных аудио"""
try:
check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
return len(list(check_audio))
except Exception as e:
logger.error(f"Ошибка при получении количества аудио: {e}")
raise DatabaseError(f"Не удалось получить количество аудио: {e}")
@track_time("get_main_keyboard", "voice_bot_service")
@track_errors("voice_bot_service", "get_main_keyboard")
def _get_main_keyboard(self):
"""Получить основную клавиатуру"""
from helper_bot.keyboards.keyboards import get_main_keyboard
return get_main_keyboard()
@track_time("send_error_to_logs", "voice_bot_service")
@track_errors("voice_bot_service", "send_error_to_logs")
async def _send_error_to_logs(self, message: str) -> None:
"""Отправить ошибку в логи"""
try:
from helper_bot.utils.helper_func import send_voice_message
await send_voice_message(
self.settings['Telegram']['important_logs'],
None,
None,
None
)
except Exception as e:
logger.error(f"Не удалось отправить ошибку в логи: {e}")
class AudioFileService:
"""Сервис для работы с аудио файлами"""
def __init__(self, bot_db):
self.bot_db = bot_db
@track_time("generate_file_name", "audio_file_service")
@track_errors("audio_file_service", "generate_file_name")
async def generate_file_name(self, user_id: int) -> str:
"""Сгенерировать имя файла для аудио"""
try:
# Проверяем есть ли запись о файле в базе данных
user_audio_count = await self.bot_db.get_user_audio_records_count(user_id=user_id)
if user_audio_count == 0:
# Если нет, то генерируем имя файла
file_name = f'message_from_{user_id}_number_1'
else:
# Иначе берем последнюю запись из БД, добавляем к ней 1
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
if file_name:
# Извлекаем номер из имени файла и увеличиваем на 1
try:
current_number = int(file_name.split('_')[-1])
new_number = current_number + 1
except (ValueError, IndexError):
new_number = user_audio_count + 1
else:
new_number = user_audio_count + 1
file_name = f'message_from_{user_id}_number_{new_number}'
return file_name
except Exception as e:
logger.error(f"Ошибка при генерации имени файла: {e}")
raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}")
@track_time("save_audio_file", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file")
async def save_audio_file(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
"""Сохранить информацию об аудио файле в базу данных"""
try:
# Проверяем существование файла перед сохранением в БД
if not await self.verify_file_exists(file_name):
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg)
raise FileOperationError(error_msg)
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД: {file_name}")
except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
@track_time("save_audio_file_with_transaction", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file_with_transaction")
async def save_audio_file_with_transaction(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
"""Сохранить информацию об аудио файле в базу данных с транзакцией"""
try:
# Проверяем существование файла перед сохранением в БД
if not await self.verify_file_exists(file_name):
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Используем транзакцию для атомарности операции
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}")
except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД с транзакцией: {e}")
@track_time("download_and_save_audio", "audio_file_service")
@track_errors("audio_file_service", "download_and_save_audio")
async def download_and_save_audio(self, bot, message, file_name: str, max_retries: int = 3) -> None:
"""Скачать и сохранить аудио файл с retry механизмом"""
last_exception = None
for attempt in range(max_retries):
try:
logger.info(f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}")
# Проверяем наличие голосового сообщения
if not message or not message.voice:
error_msg = "Сообщение или голосовое сообщение не найдено"
logger.error(error_msg)
raise FileOperationError(error_msg)
file_id = message.voice.file_id
logger.info(f"Получен file_id: {file_id}")
# Получаем информацию о файле
try:
file_info = await bot.get_file(file_id=file_id)
logger.info(f"Получена информация о файле: {file_info.file_path}")
except Exception as e:
logger.error(f"Ошибка при получении информации о файле: {e}")
raise FileOperationError(f"Не удалось получить информацию о файле: {e}")
# Скачиваем файл
try:
downloaded_file = await bot.download_file(file_path=file_info.file_path)
except Exception as e:
logger.error(f"Ошибка при скачивании файла: {e}")
raise FileOperationError(f"Не удалось скачать файл: {e}")
# Проверяем что файл успешно скачан
if not downloaded_file:
error_msg = "Не удалось скачать файл - получен пустой объект"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Получаем размер файла без изменения позиции
current_pos = downloaded_file.tell()
downloaded_file.seek(0, 2) # Переходим в конец файла
file_size = downloaded_file.tell()
downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию
logger.info(f"Файл скачан, размер: {file_size} bytes")
# Проверяем минимальный размер файла
if file_size < 100: # Минимальный размер для аудио файла
error_msg = f"Файл слишком маленький: {file_size} bytes"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Создаем директорию если она не существует
try:
os.makedirs(VOICE_USERS_DIR, exist_ok=True)
logger.info(f"Директория {VOICE_USERS_DIR} создана/проверена")
except Exception as e:
logger.error(f"Ошибка при создании директории: {e}")
raise FileOperationError(f"Не удалось создать директорию: {e}")
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
logger.info(f"Сохраняем файл по пути: {file_path}")
# Сбрасываем позицию в файле перед сохранением
downloaded_file.seek(0)
# Сохраняем файл
try:
with open(file_path, 'wb') as new_file:
new_file.write(downloaded_file.read())
except Exception as e:
logger.error(f"Ошибка при записи файла на диск: {e}")
raise FileOperationError(f"Не удалось записать файл на диск: {e}")
# Проверяем что файл действительно создался и имеет правильный размер
if not os.path.exists(file_path):
error_msg = f"Файл не был создан: {file_path}"
logger.error(error_msg)
raise FileOperationError(error_msg)
saved_file_size = os.path.getsize(file_path)
if saved_file_size != file_size:
error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}"
logger.error(error_msg)
# Удаляем поврежденный файл
try:
os.remove(file_path)
except:
pass
raise FileOperationError(error_msg)
logger.info(f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes")
return # Успешное завершение
except Exception as e:
last_exception = e
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
if attempt < max_retries - 1:
wait_time = (attempt + 1) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(f"Ожидание {wait_time} секунд перед следующей попыткой...")
await asyncio.sleep(wait_time)
else:
logger.error(f"Все {max_retries} попыток скачивания неудачны")
logger.error(f"Traceback последней ошибки: {traceback.format_exc()}")
# Если все попытки неудачны
raise FileOperationError(f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}")
@track_time("verify_file_exists", "audio_file_service")
@track_errors("audio_file_service", "verify_file_exists")
async def verify_file_exists(self, file_name: str) -> bool:
"""Проверить существование и валидность файла"""
try:
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
if not os.path.exists(file_path):
logger.warning(f"Файл не существует: {file_path}")
return False
file_size = os.path.getsize(file_path)
if file_size == 0:
logger.warning(f"Файл пустой: {file_path}")
return False
if file_size < 100: # Минимальный размер для аудио файла
logger.warning(f"Файл слишком маленький: {file_path}, размер: {file_size} bytes")
return False
logger.info(f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes")
return True
except Exception as e:
logger.error(f"Ошибка при проверке файла {file_name}: {e}")
return False

View File

@@ -0,0 +1,108 @@
import time
import html
from datetime import datetime
from typing import Optional
from helper_bot.handlers.voice.exceptions import DatabaseError
from logs.custom_logger import logger
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time
)
def format_time_ago(date_from_db: str) -> Optional[str]:
"""Форматировать время с момента последней записи"""
try:
if date_from_db is None:
return None
parse_date = datetime.strptime(date_from_db, "%Y-%m-%d %H:%M:%S")
last_voice_time_timestamp = time.mktime(parse_date.timetuple())
time_now_timestamp = time.time()
date_difference = time_now_timestamp - last_voice_time_timestamp
# Считаем минуты, часы, дни
much_minutes_ago = round(date_difference / 60, 0)
much_hour_ago = round(date_difference / 3600, 0)
much_days_ago = int(round(much_hour_ago / 24, 0))
message_with_date = ''
if much_minutes_ago <= 60:
word_minute = plural_time(1, much_minutes_ago)
# Экранируем потенциально проблемные символы
word_minute_escaped = html.escape(word_minute)
message_with_date = f'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>'
elif much_minutes_ago > 60 and much_hour_ago <= 24:
word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы
word_hour_escaped = html.escape(word_hour)
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>'
elif much_hour_ago > 24:
word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы
word_day_escaped = html.escape(word_day)
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>'
return message_with_date
except Exception as e:
logger.error(f"Ошибка при форматировании времени: {e}")
return None
def plural_time(type: int, n: float) -> str:
"""Форматировать множественное число для времени"""
word = []
if type == 1:
word = ['минуту', 'минуты', 'минут']
elif type == 2:
word = ['час', 'часа', 'часов']
elif type == 3:
word = ['день', 'дня', 'дней']
else:
return str(int(n))
if n % 10 == 1 and n % 100 != 11:
p = 0
elif 2 <= n % 10 <= 4 and (n % 100 < 10 or n % 100 >= 20):
p = 1
else:
p = 2
new_number = int(n)
return str(new_number) + ' ' + word[p]
@track_time("get_last_message_text", "voice_utils")
@track_errors("voice_utils", "get_last_message_text")
@db_query_time("get_last_message_text", "voice", "select")
async def get_last_message_text(bot_db) -> Optional[str]:
"""Получить текст сообщения о времени последней записи"""
try:
date_from_db = await bot_db.last_date_audio()
if date_from_db is None:
return None
# Преобразуем UNIX timestamp в строку для format_time_ago
date_string = datetime.fromtimestamp(date_from_db).strftime("%Y-%m-%d %H:%M:%S")
return format_time_ago(date_string)
except Exception as e:
logger.error(f"Не удалось получить дату последнего сообщения - {e}")
return None
async def validate_voice_message(message) -> bool:
"""Проверить валидность голосового сообщения"""
return message.content_type == 'voice'
@track_time("get_user_emoji_safe", "voice_utils")
@track_errors("voice_utils", "get_user_emoji_safe")
@db_query_time("get_user_emoji_safe", "voice", "select")
async def get_user_emoji_safe(bot_db, user_id: int) -> str:
"""Безопасно получить эмодзи пользователя"""
try:
user_emoji = await bot_db.get_user_emoji(user_id)
return user_emoji if user_emoji and user_emoji != "Смайл еще не определен" else "😊"
except Exception as e:
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
return "😊"

View File

@@ -0,0 +1,451 @@
import asyncio
from datetime import datetime
from pathlib import Path
from aiogram import Router, types, F
from aiogram.filters import Command, StateFilter, MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import get_first_name, update_user_info, check_user_emoji, send_voice_message
from logs.custom_logger import logger
from helper_bot.handlers.voice.constants import *
from helper_bot.handlers.voice.services import VoiceBotService
from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe
from helper_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice
from helper_bot.keyboards import get_reply_keyboard
from helper_bot.handlers.private.constants import FSM_STATES
from helper_bot.handlers.private.constants import BUTTON_TEXTS
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors,
db_query_time,
track_file_operations
)
class VoiceHandlers:
def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db
self.settings = settings
self.router = Router()
self._setup_handlers()
self._setup_middleware()
def _setup_middleware(self):
self.router.message.middleware(DependenciesMiddleware())
self.router.message.middleware(BlacklistMiddleware())
def _setup_handlers(self):
self.router.message.register(
self.cancel_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == "Отменить"
)
# Обработчик кнопки "Голосовой бот"
self.router.message.register(
self.voice_bot_button_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["VOICE_BOT"]
)
# Команды
self.router.message.register(
self.restart_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_RESTART)
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_EMOJI)
)
self.router.message.register(
self.help_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_HELP)
)
self.router.message.register(
self.start,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_START)
)
# Дополнительные команды
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_REFRESH)
)
# Обработчики состояний и кнопок
self.router.message.register(
self.standup_write,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_SPEAK
)
self.router.message.register(
self.suggest_voice,
StateFilter(STATE_STANDUP_WRITE),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.standup_listen_audio,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_LISTEN
)
# Новые обработчики кнопок
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
F.text == "🔄Сбросить прослушивания"
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
F.text == "😊Узнать эмодзи"
)
@track_time("voice_bot_button_handler", "voice_handlers")
@track_errors("voice_handlers", "voice_bot_button_handler")
async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")):
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'")
try:
# Проверяем, получал ли пользователь приветственное сообщение
welcome_received = await bot_db.check_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}")
if welcome_received:
# Если уже получал приветствие, вызываем restart_function
logger.info(f"Пользователь {message.from_user.id}: вызываем restart_function")
await self.restart_function(message, state, bot_db, settings)
else:
# Если не получал, вызываем start
logger.info(f"Пользователь {message.from_user.id}: вызываем start")
await self.start(message, state, bot_db, settings)
except Exception as e:
logger.error(f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}")
# В случае ошибки вызываем start
await self.start(message, state, bot_db, settings)
@track_time("restart_function", "voice_handlers")
@track_errors("voice_handlers", "restart_function")
async def restart_function(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id}: вызывается функция restart_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
await check_user_emoji(message)
markup = get_main_keyboard()
await message.answer(text='🎤 Записывайся или слушай!', reply_markup=markup)
await state.set_state(STATE_START)
@track_time("handle_emoji_message", "voice_handlers")
@track_errors("voice_handlers", "handle_emoji_message")
async def handle_emoji_message(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
user_emoji = await check_user_emoji(message)
await state.set_state(STATE_START)
if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
@track_time("help_function", "voice_handlers")
@track_errors("voice_handlers", "help_function")
async def help_function(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
help_message = messages.get_message(get_first_name(message), 'HELP_MESSAGE')
await message.answer(
text=help_message,
disable_web_page_preview=not settings['Telegram']['preview_link']
)
await state.set_state(STATE_START)
@track_time("start", "voice_handlers")
@track_errors("voice_handlers", "start")
@db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update")
async def start(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start")
await state.set_state(STATE_START)
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
# Создаем сервис и отправляем приветственные сообщения
voice_service = VoiceBotService(bot_db, settings)
await voice_service.send_welcome_messages(message, user_emoji)
logger.info(f"Приветственные сообщения отправлены пользователю {message.from_user.id}")
# Отмечаем, что пользователь получил приветственное сообщение
try:
await bot_db.mark_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: отмечен как получивший приветствие")
except Exception as e:
logger.error(f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}")
@track_time("cancel_handler", "voice_handlers")
@track_errors("voice_handlers", "cancel_handler")
async def cancel_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML')
await state.set_state(FSM_STATES["START"])
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
@track_time("refresh_listen_function", "voice_handlers")
@track_errors("voice_handlers", "refresh_listen_function")
async def refresh_listen_function(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
markup = get_main_keyboard()
# Очищаем прослушивания через сервис
voice_service = VoiceBotService(bot_db, settings)
await voice_service.clear_user_listenings(message.from_user.id)
listenings_cleared_message = messages.get_message(get_first_name(message), 'LISTENINGS_CLEARED_MESSAGE')
await message.answer(
text=listenings_cleared_message,
disable_web_page_preview=not settings['Telegram']['preview_link'],
reply_markup=markup
)
await state.set_state(STATE_START)
@track_time("standup_write", "voice_handlers")
@track_errors("voice_handlers", "standup_write")
async def standup_write(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
markup = types.ReplyKeyboardRemove()
record_voice_message = messages.get_message(get_first_name(message), 'RECORD_VOICE_MESSAGE')
await message.answer(text=record_voice_message, reply_markup=markup)
try:
message_with_date = await get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
except Exception as e:
logger.error(f'Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}')
await state.set_state(STATE_STANDUP_WRITE)
@track_time("suggest_voice", "voice_handlers")
@track_errors("voice_handlers", "suggest_voice")
async def suggest_voice(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
)
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
markup = get_main_keyboard()
if await validate_voice_message(message):
markup_for_voice = get_reply_keyboard_for_voice()
# Отправляем аудио в приватный канал
sent_message = await send_voice_message(
settings['Telegram']['group_for_posts'],
message,
message.voice.file_id,
markup_for_voice
)
logger.info(f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})")
# Сохраняем в базу инфо о посте
await bot_db.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id)
# Отправляем юзеру ответ и возвращаем его в меню
voice_saved_message = messages.get_message(get_first_name(message), 'VOICE_SAVED_MESSAGE')
await message.answer(text=voice_saved_message, reply_markup=markup)
await state.set_state(STATE_START)
else:
logger.warning(f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию")
unknown_content_message = messages.get_message(get_first_name(message), 'UNKNOWN_CONTENT_MESSAGE')
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await message.answer(text=unknown_content_message, reply_markup=markup)
await state.set_state(STATE_STANDUP_WRITE)
@track_time("standup_listen_audio", "voice_handlers")
@track_errors("voice_handlers", "standup_listen_audio")
@track_file_operations("voice")
@db_query_time("standup_listen_audio", "audio_moderate", "mixed")
async def standup_listen_audio(
self,
message: types.Message,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио")
markup = get_main_keyboard()
# Создаем сервис для работы с аудио
voice_service = VoiceBotService(bot_db, settings)
try:
#TODO: удалить логику из хендлера
# Получаем случайное аудио
audio_data = await voice_service.get_random_audio(message.from_user.id)
if not audio_data:
logger.warning(f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания")
no_audio_message = messages.get_message(get_first_name(message), 'NO_AUDIO_MESSAGE')
await message.answer(text=no_audio_message, reply_markup=markup)
try:
message_with_date = await get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
except Exception as e:
logger.error(f'Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}')
return
audio_for_user, date_added, user_emoji = audio_data
# Получаем путь к файлу
path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg')
# Проверяем существование файла
if not path.exists():
logger.error(f"Файл не найден: {path} для пользователя {message.from_user.id}")
# Дополнительная диагностика
logger.error(f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}")
if Path(VOICE_USERS_DIR).exists():
files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
logger.error(f"Файлы в директории: {[f.name for f in files_in_dir]}")
await message.answer(
text="Файл аудио не найден. Обратитесь к администратору.",
reply_markup=markup
)
return
# Проверяем размер файла
if path.stat().st_size == 0:
logger.error(f"Файл пустой: {path} для пользователя {message.from_user.id}")
await message.answer(
text="Файл аудио поврежден. Обратитесь к администратору.",
reply_markup=markup
)
return
voice = FSInputFile(path)
# Формируем подпись
if user_emoji:
caption = f'{user_emoji}\nДата записи: {date_added}'
else:
caption = f'Дата записи: {date_added}'
logger.info(f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}")
try:
from helper_bot.utils.rate_limiter import send_with_rate_limit
async def _send_voice():
return await message.bot.send_voice(
chat_id=message.chat.id,
voice=voice,
caption=caption,
reply_markup=markup
)
await send_with_rate_limit(_send_voice, message.chat.id)
# Маркируем сообщение как прослушанное только после успешной отправки
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id)
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id)
await message.answer(
text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup
)
except Exception as voice_error:
if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error):
# Если голосовые сообщения запрещены, отправляем информативное сообщение
logger.warning(f"Пользователь {message.from_user.id} запретил получение голосовых сообщений")
privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения"
await message.answer(text=privacy_message, reply_markup=markup)
return # Выходим без записи о прослушивании
else:
logger.error(f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}")
raise voice_error
except Exception as e:
logger.error(f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}")
await message.answer(
text="Произошла ошибка при получении аудио. Попробуйте позже.",
reply_markup=markup
)

View File

@@ -1,26 +1,36 @@
from aiogram import types from aiogram import types
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
# Local imports - metrics
from helper_bot.utils.metrics import (
track_time,
track_errors
)
def get_reply_keyboard_for_post(): def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton( builder.row(types.InlineKeyboardButton(
text="Опубликовать", callback_data="publish") text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(
text="Отклонить", callback_data="decline")
) )
builder.row(types.InlineKeyboardButton( builder.row(types.InlineKeyboardButton(
text="Отклонить", callback_data="decline") text="👮‍♂️ Забанить", callback_data="ban")
) )
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
def get_reply_keyboard(BotDB, user_id):
async def get_reply_keyboard(db, user_id):
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.add(types.KeyboardButton(text="📢Предложить свой пост")) builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
builder.add(types.KeyboardButton(text="📩Связаться с админами")) builder.row(types.KeyboardButton(text="📩Связаться с админами"))
builder.add(types.KeyboardButton(text="👋🏼Сказать пока!")) builder.row(types.KeyboardButton(text=" 🎤Голосовой бот"))
if not BotDB.get_info_about_stickers(user_id=user_id): builder.row(types.KeyboardButton(text="👋🏼Сказать пока!"))
builder.add(types.KeyboardButton(text="🤪Хочу стикеры")) if not await db.get_stickers_info(user_id):
builder.row(types.KeyboardButton(text="🤪Хочу стикеры"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
@@ -34,22 +44,26 @@ def get_reply_keyboard_leave_chat():
def get_reply_keyboard_admin(): def get_reply_keyboard_admin():
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.add(types.KeyboardButton(text="Бан (Список)")) builder.row(
builder.add(types.KeyboardButton(text="Бан по нику")) types.KeyboardButton(text="Бан (Список)"),
builder.add(types.KeyboardButton(text="Бан по ID")) types.KeyboardButton(text="Бан по нику"),
builder.add(types.KeyboardButton(text="Тестовый бан")) types.KeyboardButton(text="Бан по ID")
builder.add(types.KeyboardButton(text="Разбан (список)")) )
builder.add(types.KeyboardButton(text="Вернуться в бота")) builder.row(
types.KeyboardButton(text="Разбан (список)"),
types.KeyboardButton(text="Вернуться в бота")
)
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
@track_time("create_keyboard_with_pagination", "keyboard_service")
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list[tuple[any, any]], callback: str): @track_errors("keyboard_service", "create_keyboard_with_pagination")
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str):
""" """
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
Args: Args:
page: Номер текущей страницы. page: Номер текущей страницы (начинается с 1).
total_items: Общее количество элементов. total_items: Общее количество элементов.
array_items: Лист кортежей. Содержит в себе user_name: user_id array_items: Лист кортежей. Содержит в себе user_name: user_id
callback: Действие в коллбеке. Вернет callback вида ({callback}_{user_id}) callback: Действие в коллбеке. Вернет callback вида ({callback}_{user_id})
@@ -58,33 +72,74 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
InlineKeyboardMarkup: Клавиатура с кнопками пагинации. 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() keyboard = InlineKeyboardBuilder()
# Вычисляем стартовый номер для текущей страницы
start_index = (page - 1) * 9
# Кнопки с номерами страниц # Вычисляем стартовый номер для текущей страницы
for i in range(start_index, min(start_index + 9, len(array_items))): start_index = (page - 1) * items_per_page
keyboard.add(types.InlineKeyboardButton(
# Кнопки с элементами текущей страницы
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]}" text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}"
)) ))
keyboard.adjust(3)
next_button = types.InlineKeyboardButton( # Когда набирается 3 кнопки, добавляем ряд
text="➡️ Следующая", callback_data=f"page_{page + 1}" 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( prev_button = types.InlineKeyboardButton(
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}" text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
) )
keyboard.row(prev_button, next_button) navigation_buttons.append(prev_button)
home_button = types.InlineKeyboardButton(
text="🏠 Назад", callback_data="return") # Кнопка "Следующая" - показываем только если не последняя страница
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) keyboard.row(home_button)
k = keyboard.as_markup()
return k return keyboard.as_markup()
def create_keyboard_for_ban_reason(): def create_keyboard_for_ban_reason():
@@ -115,3 +170,33 @@ def create_keyboard_for_approve_ban():
builder.add(types.KeyboardButton(text="Отменить")) builder.add(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
def get_main_keyboard():
builder = ReplyKeyboardBuilder()
# Первая строка: Высказаться и послушать
builder.row(
types.KeyboardButton(text="🎤Высказаться"),
types.KeyboardButton(text="🎧Послушать")
)
# Вторая строка: сбросить прослушивания и узнать эмодзи
builder.row(
types.KeyboardButton(text="🔄Сбросить прослушивания"),
types.KeyboardButton(text="😊Узнать эмодзи")
)
# Третья строка: Вернуться в меню
builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True)
return markup
def get_reply_keyboard_for_voice():
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(
text="Сохранить", callback_data="save")
)
builder.row(types.InlineKeyboardButton(
text="Удалить", callback_data="delete")
)
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup

View File

@@ -2,11 +2,43 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy from aiogram.fsm.strategy import FSMStrategy
import logging
import asyncio
from typing import Optional
from helper_bot.handlers.admin import admin_router from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router from helper_bot.handlers.private import private_router
from helper_bot.handlers.voice import VoiceHandlers
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
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0):
"""Запуск бота с автоматическим перезапуском при сетевых ошибках"""
for attempt in range(max_retries):
try:
logging.info(f"Запуск бота (попытка {attempt + 1}/{max_retries})")
await dp.start_polling(bot, skip_updates=True)
break
except Exception as e:
error_msg = str(e).lower()
if any(keyword in error_msg for keyword in ['network', 'disconnected', 'timeout', 'connection']):
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt) # Exponential backoff
logging.warning(f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})")
await asyncio.sleep(delay)
continue
else:
logging.error(f"Превышено максимальное количество попыток запуска бота: {e}")
raise
else:
logging.error(f"Критическая ошибка при запуске бота: {e}")
raise
async def start_bot(bdf): async def start_bot(bdf):
@@ -14,8 +46,64 @@ async def start_bot(bdf):
bot = Bot(token=token, default=DefaultBotProperties( bot = Bot(token=token, default=DefaultBotProperties(
parse_mode='HTML', parse_mode='HTML',
link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
), timeout=30.0) # Добавляем таймаут для предотвращения зависаний ), timeout=60.0) # Увеличиваем timeout для стабильности
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) 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())
dp.update.outer_middleware(RateLimitMiddleware())
# Создаем экземпляр VoiceHandlers
voice_handlers = VoiceHandlers(bdf, bdf.settings)
voice_router = voice_handlers.router
# Middleware уже добавлены на уровне dispatcher
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router)
# Добавляем обработчик завершения для корректного закрытия
@dp.shutdown()
async def on_shutdown():
logging.info("Bot shutdown initiated, cleaning up resources...")
try:
await bot.session.close()
logging.info("Bot session closed successfully")
except Exception as e:
logging.error(f"Error closing bot session during shutdown: {e}")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, skip_updates=True)
# Запускаем HTTP сервер для метрик параллельно с ботом
metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0')
metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080)
try:
# Запускаем метрики сервер
await start_metrics_server(metrics_host, metrics_port)
logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}")
logging.info("✅ Метрики будут обновляться в реальном времени через middleware")
# Запускаем бота с retry логикой
await start_bot_with_retry(bot, dp)
logging.info("✅ Бот запущен")
except Exception as e:
logging.error(f"❌ Ошибка запуска бота: {e}")
raise
finally:
# Останавливаем метрики сервер при завершении
try:
await stop_metrics_server()
except Exception as e:
logging.error(f"Error stopping metrics server: {e}")
# Закрываем сессию бота
try:
await bot.session.close()
except Exception as e:
logging.error(f"Error closing bot session: {e}")
return bot

View File

@@ -1,61 +1,82 @@
import asyncio import asyncio
from typing import Any, Dict, Union from typing import Any, Dict, Union, List
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message from aiogram.types import Message
class AlbumMiddleware(BaseMiddleware): class AlbumMiddleware(BaseMiddleware):
def __init__(self, latency: Union[int, float] = 0.01): # Уменьшено с 0.1 до 0.01 """
# Initialize latency and album_data dictionary Middleware для обработки медиа групп в Telegram.
self.latency = latency Собирает все сообщения одной медиа группы и передает их как album в data.
self.album_data = {} """
# def __init__(self, latency: Union[int, float] = 0.01):
def collect_album_messages(self, event: Message):
""" """
Collect messages of the same media group. Инициализация middleware.
Args:
latency: Задержка в секундах для сбора всех сообщений медиа группы
""" """
# # Check if media_group_id exists in album_data super().__init__()
self.latency = latency
self.album_data: Dict[str, Dict[str, List[Message]]] = {}
def collect_album_messages(self, event: Message) -> int:
"""
Собирает сообщения одной медиа группы.
Args:
event: Сообщение для обработки
Returns:
Количество сообщений в текущей медиа группе
"""
if not event.media_group_id:
return 0
if event.media_group_id not in self.album_data: if event.media_group_id not in self.album_data:
# # Create a new entry for the media group
self.album_data[event.media_group_id] = {"messages": []} self.album_data[event.media_group_id] = {"messages": []}
#
# # Append the new message to the media group
self.album_data[event.media_group_id]["messages"].append(event) self.album_data[event.media_group_id]["messages"].append(event)
#
# # Return the total number of messages in the current media group
return len(self.album_data[event.media_group_id]["messages"]) return len(self.album_data[event.media_group_id]["messages"])
#
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
""" """
Main middleware logic. Основная логика middleware.
Args:
handler: Обработчик события
event: Событие (сообщение)
data: Данные для передачи в обработчик
Returns:
Результат выполнения обработчика
""" """
# # If the event has no media_group_id, pass it to the handler immediately # Если у события нет media_group_id, передаем его обработчику сразу
if not event.media_group_id: if not event.media_group_id:
return await handler(event, data) return await handler(event, data)
#
# # Collect messages of the same media group # Собираем сообщения одной медиа группы
total_before = self.collect_album_messages(event) total_before = self.collect_album_messages(event)
#
# # Wait for a specified latency period # Ждем указанный период для сбора всех сообщений
await asyncio.sleep(self.latency) await asyncio.sleep(self.latency)
#
# # Check the total number of messages after the latency # Проверяем количество сообщений после задержки
total_after = len(self.album_data[event.media_group_id]["messages"]) total_after = len(self.album_data[event.media_group_id]["messages"])
#
# # If new messages were added during the latency, exit # Если за время задержки добавились новые сообщения, выходим
if total_before != total_after: if total_before != total_after:
return return
#
# # Sort the album messages by message_id and add to data # Сортируем сообщения по message_id и добавляем в data
album_messages = self.album_data[event.media_group_id]["messages"] album_messages = self.album_data[event.media_group_id]["messages"]
album_messages.sort(key=lambda x: x.message_id) album_messages.sort(key=lambda x: x.message_id)
data["album"] = album_messages data["album"] = album_messages
#
# # Remove the media group from tracking to free up memory # Удаляем медиа группу из отслеживания для освобождения памяти
del self.album_data[event.media_group_id] del self.album_data[event.media_group_id]
# # Call the original event handler
# Вызываем оригинальный обработчик события
return await handler(event, data) return await handler(event, data)
#

View File

@@ -1,7 +1,9 @@
from typing import Dict, Any from typing import Dict, Any
import html import html
from datetime import datetime
from aiogram import BaseMiddleware, types from aiogram import BaseMiddleware, types
from aiogram.types import TelegramObject, Message, CallbackQuery
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger from logs.custom_logger import logger
@@ -10,17 +12,47 @@ BotDB = bdf.get_db()
class BlacklistMiddleware(BaseMiddleware): class BlacklistMiddleware(BaseMiddleware):
async def __call__(self, handler, event: types.Message, data: Dict[str, Any]) -> Any: async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
logger.info(f'Вызов BlacklistMiddleware для пользователя {event.from_user.username}') # Проверяем тип события и получаем пользователя
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): if await BotDB.check_user_in_blacklist(user.id):
logger.info(f'BlacklistMiddleware результат для пользователя: {event.from_user.username} заблокирован!') logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!')
user_info = await BotDB.get_blacklist_users_by_id_async(event.from_user.id) user_info = await BotDB.get_blacklist_users_by_id(user.id)
# Экранируем потенциально проблемные символы # Экранируем потенциально проблемные символы
reason = html.escape(str(user_info[2])) if user_info[2] else "Не указана" reason = html.escape(str(user_info[1])) if user_info and user_info[1] else "Не указана"
date_unban = html.escape(str(user_info[3])) if user_info[3] else "Не указана"
# Преобразуем timestamp в человекочитаемый формат
if user_info and user_info[2]:
try:
timestamp = int(user_info[2])
date_unban = datetime.fromtimestamp(timestamp).strftime("%d-%m-%Y %H:%M")
except (ValueError, TypeError):
date_unban = "Не указана"
else:
date_unban = "Не указана"
# Отправляем сообщение в зависимости от типа события
if isinstance(event, Message):
await event.answer( await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}") f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}")
elif isinstance(event, CallbackQuery):
await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
show_alert=True)
return False return False
logger.info(f'BlacklistMiddleware результат для пользователя: {event.from_user.username} доступ разрешен')
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен')
return await handler(event, data) return await handler(event, data)

View File

@@ -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)

View File

@@ -0,0 +1,480 @@
"""
Enhanced Metrics middleware for aiogram 3.x.
Automatically collects ALL available metrics for comprehensive monitoring.
"""
from typing import Any, Awaitable, Callable, Dict, Union, Optional
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery
from aiogram.enums import ChatType
import time
import logging
import asyncio
from ..utils.metrics import metrics
# Import button command mapping
try:
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
from ..handlers.voice.constants import (
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
COMMAND_MAPPING as VOICE_COMMAND_MAPPING,
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING
)
except ImportError:
# Fallback if constants not available
BUTTON_COMMAND_MAPPING = {}
CALLBACK_COMMAND_MAPPING = {}
ADMIN_BUTTON_COMMAND_MAPPING = {}
ADMIN_COMMANDS = {}
VOICE_BUTTON_COMMAND_MAPPING = {}
VOICE_COMMAND_MAPPING = {}
VOICE_CALLBACK_COMMAND_MAPPING = {}
class MetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for automatic collection of ALL available metrics."""
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
# Metrics update intervals
self.last_active_users_update = 0
self.active_users_update_interval = 300 # 5 minutes
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Process event and collect comprehensive metrics."""
# Update active users periodically
current_time = time.time()
if current_time - self.last_active_users_update > self.active_users_update_interval:
await self._update_active_users_metric()
self.last_active_users_update = current_time
# Extract command and event info
command_info = None
event_metrics = {}
# Process event based on type
if hasattr(event, 'message') and event.message:
event_metrics = await self._record_comprehensive_message_metrics(event.message)
command_info = self._extract_command_info_with_fallback(event.message)
elif hasattr(event, 'callback_query') and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(event.callback_query)
command_info = self._extract_callback_command_info_with_fallback(event.callback_query)
elif isinstance(event, Message):
event_metrics = await self._record_comprehensive_message_metrics(event)
command_info = self._extract_command_info_with_fallback(event)
elif isinstance(event, CallbackQuery):
event_metrics = await self._record_comprehensive_callback_metrics(event)
command_info = self._extract_callback_command_info_with_fallback(event)
else:
event_metrics = await self._record_unknown_event_metrics(event)
if command_info:
self.logger.info(f"📊 Command info extracted: {command_info}")
else:
self.logger.warning(f"📊 No command info extracted for event type: {type(event).__name__}")
# Execute handler with comprehensive timing and metrics
start_time = time.time()
try:
result = await handler(event, data)
duration = time.time() - start_time
# Record successful execution metrics
handler_name = self._get_handler_name(handler)
metrics.record_method_duration(
handler_name,
duration,
"handler",
"success"
)
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"success"
)
await self._record_additional_success_metrics(event, event_metrics, handler_name)
return result
except Exception as e:
duration = time.time() - start_time
# Record error metrics
handler_name = self._get_handler_name(handler)
error_type = type(e).__name__
metrics.record_method_duration(
handler_name,
duration,
"handler",
"error"
)
metrics.record_error(
error_type,
"handler",
handler_name
)
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"error"
)
await self._record_additional_error_metrics(event, event_metrics, handler_name, error_type)
raise
finally:
# Record middleware execution time
middleware_duration = time.time() - start_time
metrics.record_middleware("MetricsMiddleware", middleware_duration, "success")
async def _update_active_users_metric(self):
"""Periodically update active users metric from database."""
try:
#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
from ..utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
bot_db = bdf.get_db()
# Используем правильные методы AsyncBotDB для выполнения запросов
# Простой подсчет всех пользователей в базе
total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
total_users_result = await bot_db.fetch_one(total_users_query)
total_users = total_users_result['total'] if total_users_result else 1
# Подсчет активных за день пользователей (date_changed - это Unix timestamp)
daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))"
daily_users_result = await bot_db.fetch_one(daily_users_query)
daily_users = daily_users_result['daily'] if daily_users_result else 1
# Устанавливаем метрики с правильными лейблами
metrics.set_active_users(daily_users, "daily")
metrics.set_total_users(total_users)
self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)")
except Exception as e:
self.logger.error(f"❌ Failed to update users metric: {e}")
# Устанавливаем 1 как fallback
metrics.set_active_users(1, "daily")
metrics.set_total_users(1)
async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]:
"""Record comprehensive message metrics."""
# 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")
return {
'message_type': message_type,
'chat_type': chat_type,
'user_id': message.from_user.id if message.from_user else None,
'is_bot': message.from_user.is_bot if message.from_user else False
}
async def _record_comprehensive_callback_metrics(self, callback: CallbackQuery) -> Dict[str, Any]:
"""Record comprehensive callback metrics."""
# Record callback message
metrics.record_message("callback_query", "callback", "callback_handler")
return {
'callback_data': callback.data,
'user_id': callback.from_user.id if callback.from_user else None,
'is_bot': callback.from_user.is_bot if callback.from_user else False
}
async def _record_unknown_event_metrics(self, event: TelegramObject) -> Dict[str, Any]:
"""Record metrics for unknown event types."""
# Record unknown event
metrics.record_message("unknown", "unknown", "unknown_handler")
return {
'event_type': type(event).__name__,
'event_data': str(event)[:100] if hasattr(event, '__str__') else "unknown"
}
def _extract_command_info_with_fallback(self, message: Message) -> Optional[Dict[str, str]]:
"""Extract command information with fallback for unknown commands."""
if not message.text:
return None
# Check if it's a slash command
if message.text.startswith('/'):
command_name = message.text.split()[0][1:] # Remove '/' and get command name
# Check if it's an admin command
if command_name in ADMIN_COMMANDS:
return {
'command': ADMIN_COMMANDS[command_name],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_handler"
}
# Check if it's a voice bot command
elif command_name in VOICE_COMMAND_MAPPING:
return {
'command': VOICE_COMMAND_MAPPING[command_name],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_command_handler"
}
else:
# FALLBACK: Record unknown command
return {
'command': command_name,
'user_type': "user" if message.from_user else "unknown",
'handler_type': "unknown_command_handler"
}
# Check if it's an admin button click
if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
return {
'command': ADMIN_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_button_handler"
}
# Check if it's a regular button click (text button)
if message.text in BUTTON_COMMAND_MAPPING:
return {
'command': BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "button_handler"
}
# Check if it's a voice bot button click
if message.text in VOICE_BUTTON_COMMAND_MAPPING:
return {
'command': VOICE_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_button_handler"
}
# FALLBACK: Record ANY text message as a command for metrics
if message.text and len(message.text.strip()) > 0:
return {
'command': f"text",
'user_type': "user" if message.from_user else "unknown",
'handler_type': "text_message_handler"
}
return None
def _extract_callback_command_info_with_fallback(self, callback: CallbackQuery) -> Optional[Dict[str, str]]:
"""Extract callback command information with fallback."""
if not callback.data:
return None
# Extract command from callback data
parts = callback.data.split(':', 1)
if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
return {
'command': CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "callback_handler"
}
# Check if it's a voice bot callback
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
return {
'command': VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "voice_callback_handler"
}
# FALLBACK: Record unknown callback
if parts:
callback_data = parts[0]
# Группируем похожие callback'и по паттернам
if callback_data.startswith("ban_") and callback_data[4:].isdigit():
# callback_ban_123456 -> callback_ban
command = "callback_ban"
elif callback_data.startswith("page_") and callback_data[5:].isdigit():
# callback_page_2 -> callback_page
command = "callback_page"
else:
# Для остальных неизвестных callback'ов оставляем как есть
command = f"callback_{callback_data[:20]}"
return {
'command': command,
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "unknown_callback_handler"
}
return None
async def _record_additional_success_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str):
"""Record additional success metrics."""
try:
# Record rate limiting metrics (if applicable)
if hasattr(event, 'from_user') and event.from_user:
# You can add rate limiting logic here
pass
# Record user activity metrics
if event_metrics.get('user_id'):
# This could trigger additional user activity tracking
pass
except Exception as e:
self.logger.error(f"❌ Error recording additional success metrics: {e}")
async def _record_additional_error_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str, error_type: str):
"""Record additional error metrics."""
try:
# Record specific error context
if event_metrics.get('user_id'):
# You can add user-specific error tracking here
pass
except Exception as e:
self.logger.error(f"❌ Error recording additional error metrics: {e}")
def _get_handler_name(self, handler: Callable) -> str:
"""Extract handler name efficiently."""
# Check various ways to get handler name
if hasattr(handler, '__name__') and handler.__name__ != '<lambda>':
return handler.__name__
elif hasattr(handler, '__qualname__') and handler.__qualname__ != '<lambda>':
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"
class DatabaseMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for database operation metrics."""
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 database metrics."""
# Check if this handler involves database operations
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
# Record middleware start
start_time = time.time()
try:
result = await handler(event, data)
# Record successful database operation
duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success")
return result
except Exception as e:
# Record failed database operation
duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"database_middleware",
handler_name
)
raise
class ErrorMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for error tracking and metrics."""
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 error metrics."""
# Record middleware start
start_time = time.time()
try:
result = await handler(event, data)
# Record successful error handling
duration = time.time() - start_time
metrics.record_middleware("ErrorMetricsMiddleware", duration, "success")
return result
except Exception as e:
# Record error metrics
duration = time.time() - start_time
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"error_middleware",
handler_name
)
raise

View File

@@ -0,0 +1,57 @@
"""
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
"""
from typing import Callable, Dict, Any, Awaitable, Union
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, InlineQuery, ChatMemberUpdated, Update
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
from logs.custom_logger import logger
from helper_bot.utils.rate_limiter import telegram_rate_limiter
class RateLimitMiddleware(BaseMiddleware):
"""Middleware для автоматического rate limiting входящих сообщений"""
def __init__(self):
super().__init__()
self.rate_limiter = telegram_rate_limiter
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
data: Dict[str, Any]
) -> Any:
"""Обрабатывает событие с rate limiting"""
# Извлекаем сообщение из Update
message = None
if isinstance(event, Update):
message = event.message
elif isinstance(event, Message):
message = event
# Применяем rate limiting только к сообщениям
if message is not None:
chat_id = message.chat.id
# Обертываем handler в rate limiting
async def rate_limited_handler():
try:
return await handler(event, data)
except (TelegramRetryAfter, TelegramAPIError) as e:
logger.warning(f"Rate limit error in middleware: {e}")
# Middleware не должен перехватывать эти ошибки,
# пусть их обрабатывает rate_limiter в функциях отправки
raise
# Применяем rate limiting к handler
return await self.rate_limiter.send_with_rate_limit(
rate_limited_handler,
chat_id
)
else:
# Для других типов событий просто вызываем handler
return await handler(event, data)

124
helper_bot/scripts/monitor_bot.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/bin/bash
# Script for monitoring and auto-restarting the Telegram bot
# Usage: ./monitor_bot.sh
set -e
# Configuration
BOT_CONTAINER="telegram-helper-bot"
HEALTH_ENDPOINT="http://localhost:8080/health"
CHECK_INTERVAL=60 # seconds
MAX_FAILURES=3
LOG_FILE="logs/bot_monitor.log"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging function
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
# Check if container is running
check_container_running() {
if docker ps --format "table {{.Names}}" | grep -q "^${BOT_CONTAINER}$"; then
return 0
else
return 1
fi
}
# Check health endpoint
check_health() {
if curl -f --connect-timeout 5 --max-time 10 "$HEALTH_ENDPOINT" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
# Restart container
restart_container() {
log "${YELLOW}Restarting container ${BOT_CONTAINER}...${NC}"
if docker restart "$BOT_CONTAINER" >/dev/null 2>&1; then
log "${GREEN}Container restarted successfully${NC}"
# Wait for container to be ready
log "Waiting for container to be ready..."
sleep 30
# Check if container is healthy
local attempts=0
while [ $attempts -lt 10 ]; do
if check_health; then
log "${GREEN}Container is healthy after restart${NC}"
return 0
fi
attempts=$((attempts + 1))
sleep 10
done
log "${RED}Container failed to become healthy after restart${NC}"
return 1
else
log "${RED}Failed to restart container${NC}"
return 1
fi
}
# Main monitoring loop
main() {
log "${GREEN}Starting bot monitoring...${NC}"
log "Container: $BOT_CONTAINER"
log "Health endpoint: $HEALTH_ENDPOINT"
log "Check interval: ${CHECK_INTERVAL}s"
log "Max failures: $MAX_FAILURES"
local failure_count=0
while true; do
# Check if container is running
if ! check_container_running; then
log "${RED}Container $BOT_CONTAINER is not running!${NC}"
if restart_container; then
failure_count=0
else
failure_count=$((failure_count + 1))
fi
else
# Check health endpoint
if check_health; then
if [ $failure_count -gt 0 ]; then
log "${GREEN}Container recovered, resetting failure count${NC}"
failure_count=0
fi
log "${GREEN}Container is healthy${NC}"
else
failure_count=$((failure_count + 1))
log "${YELLOW}Health check failed (${failure_count}/${MAX_FAILURES})${NC}"
if [ $failure_count -ge $MAX_FAILURES ]; then
log "${RED}Max failures reached, restarting container${NC}"
if restart_container; then
failure_count=0
else
log "${RED}Failed to restart container after max failures${NC}"
fi
fi
fi
fi
sleep "$CHECK_INTERVAL"
done
}
# Handle script interruption
trap 'log "Monitoring stopped by user"; exit 0' INT TERM
# Run main function
main "$@"

View File

@@ -0,0 +1,170 @@
"""
HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
Provides /metrics endpoint and health check for the bot.
"""
import asyncio
from aiohttp import web
from typing import Optional
from .utils.metrics import metrics
# Импортируем логгер из проекта
try:
from logs.custom_logger import logger
except ImportError:
# Fallback для случаев, когда custom_logger недоступен
import logging
logger = logging.getLogger(__name__)
class MetricsServer:
"""HTTP server for Prometheus metrics and health checks."""
def __init__(self, host: str = '0.0.0.0', port: int = 8080):
self.host = host
self.port = port
self.app = web.Application()
self.runner: Optional[web.AppRunner] = None
self.site: Optional[web.TCPSite] = None
# Настраиваем роуты
self.app.router.add_get('/metrics', self.metrics_handler)
self.app.router.add_get('/health', self.health_handler)
async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus scraping."""
try:
logger.debug("Generating metrics...")
# Проверяем, что metrics доступен
if not metrics:
logger.error("Metrics object is not available")
return web.Response(
text="Metrics not available",
status=500
)
# Генерируем метрики в формате Prometheus
metrics_data = metrics.get_metrics()
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:
logger.error(f"Error generating metrics: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
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."""
try:
# Проверяем доступность метрик
if not metrics:
return web.Response(
text="ERROR: Metrics not available",
content_type='text/plain',
status=503
)
# Проверяем, что можем получить метрики
try:
metrics_data = metrics.get_metrics()
if not metrics_data:
return web.Response(
text="ERROR: Empty metrics",
content_type='text/plain',
status=503
)
except Exception as e:
return web.Response(
text=f"ERROR: Metrics generation failed: {e}",
content_type='text/plain',
status=503
)
return web.Response(
text="OK",
content_type='text/plain',
status=200
)
except Exception as e:
logger.error(f"Health check failed: {e}")
return web.Response(
text=f"ERROR: Health check failed: {e}",
content_type='text/plain',
status=500
)
async def start(self) -> None:
"""Start the HTTP 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()
logger.info(f"Metrics server started on {self.host}:{self.port}")
logger.info("Available endpoints:")
logger.info(f" - /metrics - Prometheus metrics")
logger.info(f" - /health - Health check")
except Exception as e:
logger.error(f"Failed to start metrics server: {e}")
raise
async def stop(self) -> None:
"""Stop the HTTP server."""
try:
if self.site:
await self.site.stop()
logger.info("Metrics server site stopped")
if self.runner:
await self.runner.cleanup()
logger.info("Metrics server runner cleaned up")
except Exception as e:
logger.error(f"Error stopping metrics server: {e}")
async def __aenter__(self):
"""Async context manager entry."""
await self.start()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.stop()
# Глобальный экземпляр сервера для использования в main.py
metrics_server: Optional[MetricsServer] = None
async def start_metrics_server(host: str = '0.0.0.0', port: int = 8080) -> MetricsServer:
"""Start metrics server and return instance."""
global metrics_server
metrics_server = MetricsServer(host, port)
await metrics_server.start()
return metrics_server
async def stop_metrics_server() -> None:
"""Stop metrics server if running."""
global metrics_server
if metrics_server:
try:
await metrics_server.stop()
logger.info("Metrics server stopped successfully")
except Exception as e:
logger.error(f"Error stopping metrics server: {e}")
finally:
metrics_server = None

View File

@@ -0,0 +1,185 @@
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
from .metrics import (
track_time,
track_errors,
db_query_time
)
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
@track_time("auto_unban_users", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "auto_unban_users")
@db_query_time("auto_unban_users", "users", "mixed")
async def auto_unban_users(self):
"""
Основная функция автоматического разбана пользователей.
Получает список пользователей, у которых истекает срок блокировки сегодня,
и удаляет их из черного списка.
"""
try:
logger.info("Запуск автоматического разбана пользователей")
# Получаем текущий UNIX timestamp
current_timestamp = int(datetime.now().timestamp())
logger.info(f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}")
# Получаем список пользователей для разблокировки
users_to_unban = await self.bot_db.get_users_for_unblock_today(current_timestamp)
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 in users_to_unban:
try:
result = await self.bot_db.delete_user_blacklist(user_id)
if result:
success_count += 1
logger.info(f"Пользователь {user_id} успешно разблокирован")
else:
failed_count += 1
failed_users.append(f"{user_id}")
logger.error(f"Ошибка при разблокировке пользователя {user_id}")
except Exception as e:
failed_count += 1
failed_users.append(f"{user_id}")
logger.error(f"Исключение при разблокировке пользователя {user_id}: {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"🤖 <b>Отчет об автоматическом разбане</b>\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 += "✅ <b>Разблокированные пользователи:</b>\n"
for user_id in all_users:
if str(user_id) not in failed_users:
report += f"• ID: {user_id}\n"
report += "\n"
if failed_users:
report += "❌ <b>Ошибки при разблокировке:</b>\n"
for user in failed_users:
report += f"{user}\n"
return report
@track_time("send_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_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}")
@track_time("send_error_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_error_report")
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"🚨 <b>Ошибка автоматического разбана</b>\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

View File

@@ -1,41 +1,72 @@
import configparser
import os import os
import sys import sys
from dotenv import load_dotenv
from database.db import BotDB from database.async_db import AsyncBotDB
current_dir = os.getcwd()
class BaseDependencyFactory: class BaseDependencyFactory:
def __init__(self): def __init__(self):
# Загрузка настроек из settings.ini project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
config_path = os.path.join(sys.path[0], 'settings.ini') env_path = os.path.join(project_dir, '.env')
self.config = configparser.ConfigParser() if os.path.exists(env_path):
self.config.read(config_path) load_dotenv(env_path)
self.settings = {}
self.database = BotDB(current_dir, 'tg-bot-database.db')
for section in self.config.sections(): self.settings = {}
self.settings[section] = {}
for key in self.config[section]: database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db')
# Преобразование значений в соответствующий тип if not os.path.isabs(database_path):
if key == 'PREVIEW_LINK': database_path = os.path.join(project_dir, database_path)
self.settings[section][key] = self.config.getboolean(section, key)
elif key == 'LOGS' or key == 'TEST': self.database = AsyncBotDB(database_path)
self.settings[section][key] = self.config.getboolean(section, key)
else: self._load_settings_from_env()
self.settings[section][key] = self.config.get(section, key)
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'))
}
self.settings['Metrics'] = {
'host': os.getenv('METRICS_HOST', '0.0.0.0'),
'port': self._parse_int(os.getenv('METRICS_PORT', '8080'))
}
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): def get_settings(self):
return self.settings return self.settings
def get_db(self) -> BotDB: def get_db(self) -> AsyncBotDB:
"""Возвращает подключение к базе данных.""" """Возвращает подключение к базе данных."""
return self.database return self.database
# Создаем единый экземпляр для всего приложения
_global_instance = None _global_instance = None
def get_global_instance(): def get_global_instance():

View File

@@ -1,24 +1,40 @@
import html import html
import os import os
import random import random
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
try: try:
import emoji as _emoji_lib import emoji as _emoji_lib
except Exception: _emoji_lib_available = True
except ImportError:
_emoji_lib = None _emoji_lib = None
_emoji_lib_available = False
from aiogram import types from aiogram import types
from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio, InputMediaDocument
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
from logs.custom_logger import logger from logs.custom_logger import logger
from database.models import TelegramPost
# Local imports - metrics
from .metrics import (
track_time,
track_errors,
db_query_time,
track_media_processing,
track_file_operations,
)
bdf = get_global_instance() bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB
BotDB = bdf.get_db() BotDB = bdf.get_db()
GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs']
if _emoji_lib is not None: if _emoji_lib_available and _emoji_lib is not None:
emoji_list = list(_emoji_lib.EMOJI_DATA.keys()) emoji_list = list(_emoji_lib.EMOJI_DATA.keys())
else: else:
# Fallback minimal emoji set for environments without the 'emoji' package (e.g., CI tests) # Fallback minimal emoji set for environments without the 'emoji' package (e.g., CI tests)
@@ -43,6 +59,8 @@ def safe_html_escape(text: str) -> str:
return html.escape(str(text)) 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: def get_first_name(message: types.Message) -> str:
""" """
Безопасно получает и экранирует имя пользователя для использования в HTML разметке. Безопасно получает и экранирует имя пользователя для использования в HTML разметке.
@@ -98,45 +116,93 @@ def get_text_message(post_text: str, first_name: str, username: str = None):
else: else:
return f'Пост из ТГ:\n{safe_post_text}\n\nАвтор поста: {author_info}' return f'Пост из ТГ:\n{safe_post_text}\n\nАвтор поста: {author_info}'
@track_time("download_file", "helper_func")
async def download_file(message: types.Message, file_id: str): @track_errors("helper_func", "download_file")
@track_file_operations("unknown")
async def download_file(message: types.Message, file_id: str, content_type: str = None) -> Optional[str]:
""" """
Скачивает файл по file_id из Telegram. Скачивает файл по file_id из Telegram и сохраняет в соответствующую папку.
Args: Args:
message: сообщение message: сообщение
file_id: File ID фотографии file_id: File ID файла
filename: Имя файла, под которым будет сохранено фото content_type: тип контента (photo, video, audio, voice, video_note)
Returns: Returns:
Путь к сохраненному файлу, если файл был скачан успешно, иначе None Путь к сохраненному файлу, если файл был скачан успешно, иначе None
""" """
start_time = time.time()
try: try:
os.makedirs("files", exist_ok=True) # Валидация параметров
os.makedirs("files/photos", exist_ok=True) if not file_id or not message or not message.bot:
os.makedirs("files/videos", exist_ok=True) logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
os.makedirs("files/music", exist_ok=True)
os.makedirs("files/voice", exist_ok=True)
os.makedirs("files/video_notes", exist_ok=True)
file = await message.bot.get_file(file_id)
file_path = os.path.join("files", file.file_path)
await message.bot.download_file(file_path=file.file_path, destination=file_path)
return file_path
except Exception as e:
logger.error(f"Ошибка скачивания фотографии: {e}")
return None return None
# Определяем папку по типу контента
type_folders = {
'photo': 'photos',
'video': 'videos',
'audio': 'music',
'voice': 'voice',
'video_note': 'video_notes'
}
folder = type_folders.get(content_type, 'other')
base_path = "files"
full_folder_path = os.path.join(base_path, folder)
# Создаем необходимые папки
os.makedirs(base_path, exist_ok=True)
os.makedirs(full_folder_path, exist_ok=True)
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}")
# Получаем информацию о файле
file = await message.bot.get_file(file_id)
if not file or not file.file_path:
logger.error(f"download_file: Не удалось получить информацию о файле {file_id}")
return None
# Генерируем уникальное имя файла
original_filename = os.path.basename(file.file_path)
file_extension = os.path.splitext(original_filename)[1] or '.bin'
safe_filename = f"{file_id}{file_extension}"
file_path = os.path.join(full_folder_path, safe_filename)
# Скачиваем файл
await message.bot.download_file(file_path=file.file_path, destination=file_path)
# Проверяем, что файл действительно скачался
if not os.path.exists(file_path):
logger.error(f"download_file: Файл не был скачан - {file_path}")
return None
file_size = os.path.getsize(file_path)
download_time = time.time() - start_time
logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с")
return file_path
except Exception as e:
download_time = time.time() - start_time
logger.error(f"download_file: Ошибка скачивания файла {file_id}: {e}, время: {download_time:.2f}с")
return None
@track_time("prepare_media_group_from_middlewares", "helper_func")
@track_errors("helper_func", "prepare_media_group_from_middlewares")
@track_media_processing("media_group")
async def prepare_media_group_from_middlewares(album, post_caption: str = ''): async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
""" """
Создает MediaGroup. Создает MediaGroup согласно best practices aiogram 3.x.
Args: Args:
album: Album объект из Telegram API. album: Album объект из Telegram API (список сообщений).
post_caption: Текст подписи к первому фото. post_caption: Текст подписи к первому медиа файлу.
Returns: Returns:
Список InputMediaPhoto (MediaGroup). Список InputMedia объектов для MediaGroup.
""" """
# Экранируем post_caption для безопасного использования в HTML # Экранируем post_caption для безопасного использования в HTML
safe_post_caption = html.escape(str(post_caption)) if post_caption else "" safe_post_caption = html.escape(str(post_caption)) if post_caption else ""
@@ -146,106 +212,261 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
for i, message in enumerate(album): for i, message in enumerate(album):
if message.photo: if message.photo:
file_id = message.photo[-1].file_id file_id = message.photo[-1].file_id
media_type = 'photo' # Для фото используем InputMediaPhoto
if i == 0: # Первое фото получает подпись
media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaPhoto(media=file_id))
elif message.video: elif message.video:
file_id = message.video.file_id file_id = message.video.file_id
media_type = 'video' # Для видео используем InputMediaVideo
if i == 0: # Первое видео получает подпись
media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaVideo(media=file_id))
elif message.audio: elif message.audio:
file_id = message.audio.file_id file_id = message.audio.file_id
media_type = 'audio' # Для аудио используем InputMediaAudio
else: if i == 0: # Первое аудио получает подпись
# Если нет фото, видео или аудио, пропускаем сообщение
continue
# Формируем объект MediaGroup с учетом типа медиа
if i == len(album) - 1:
if media_type == 'photo':
media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption))
elif media_type == 'video':
media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption))
elif media_type == 'audio':
media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption)) media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption))
else: else:
if media_type == 'photo':
media_group.append(InputMediaPhoto(media=file_id))
elif media_type == 'video':
media_group.append(InputMediaVideo(media=file_id))
elif media_type == 'audio':
media_group.append(InputMediaAudio(media=file_id)) media_group.append(InputMediaAudio(media=file_id))
elif message.document:
return media_group # Возвращаем MediaGroup file_id = message.document.file_id
# Для документов используем InputMediaDocument (если поддерживается)
if i == 0: # Первый документ получает подпись
async def add_in_db_media_mediagroup(sent_message, bot_db): media_group.append(InputMediaDocument(media=file_id, caption=safe_post_caption))
"""
Идентификатор медиа-группы
Args:
sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных
Returns:
Список InputFile (FSInputFile).
"""
media_group_message_id = sent_message[-1].message_id # Получаем идентификатор медиа-группы
for i, message in enumerate(sent_message):
if message.photo:
file_id = message.photo[-1].file_id
file_path = await download_file(message, file_id=file_id)
bot_db.add_post_content_in_db(media_group_message_id, message.message_id, file_path, 'photo')
elif message.video:
file_id = message.video.file_id
file_path = await download_file(message, file_id=file_id)
bot_db.add_post_content_in_db(media_group_message_id, message.message_id, file_path, 'video')
else: else:
# Если нет фото, видео или аудио, или другой контент, пропускаем сообщение media_group.append(InputMediaDocument(media=file_id))
else:
# Если нет поддерживаемого медиа, пропускаем сообщение
continue continue
return media_group
async def add_in_db_media(sent_message, bot_db): @track_time("add_in_db_media_mediagroup", "helper_func")
@track_errors("helper_func", "add_in_db_media_mediagroup")
@track_media_processing("media_group")
@db_query_time("add_in_db_media_mediagroup", "posts", "insert")
async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int] = None) -> bool:
""" """
Добавляет контент медиа-группы в базу данных
Args:
sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных
main_post_id: ID основного поста медиагруппы (если не указан, используется последний message_id)
Returns:
bool: True если весь контент успешно добавлен, False в случае ошибки
"""
start_time = time.time()
try:
# Валидация параметров
if not sent_message or not bot_db or not isinstance(sent_message, list):
logger.error("add_in_db_media_mediagroup: Неверные параметры - sent_message, bot_db или sent_message не является списком")
return False
if len(sent_message) == 0:
logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа")
return False
# Используем переданный main_post_id или ID последнего сообщения
post_id = main_post_id or sent_message[-1].message_id
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю медиагруппу из {len(sent_message)} сообщений, post_id: {post_id}")
processed_count = 0
failed_count = 0
for i, message in enumerate(sent_message):
try:
content_type = None
file_id = None
# Определяем тип контента и file_id
if message.photo:
content_type = 'photo'
file_id = message.photo[-1].file_id
elif message.video:
content_type = 'video'
file_id = message.video.file_id
elif message.audio:
content_type = 'audio'
file_id = message.audio.file_id
elif message.voice:
content_type = 'voice'
file_id = message.voice.file_id
elif message.video_note:
content_type = 'video_note'
file_id = message.video_note.file_id
else:
logger.warning(f"add_in_db_media_mediagroup: Неподдерживаемый тип контента в сообщении {i+1}/{len(sent_message)}")
failed_count += 1
continue
if not file_id:
logger.error(f"add_in_db_media_mediagroup: file_id отсутствует в сообщении {i+1}/{len(sent_message)}")
failed_count += 1
continue
logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}")
# Скачиваем файл
file_path = await download_file(message, file_id=file_id, content_type=content_type)
if not file_path:
logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}")
failed_count += 1
continue
# Добавляем в базу данных
success = await bot_db.add_post_content(post_id, message.message_id, file_path, content_type)
if not success:
logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}")
# Удаляем скачанный файл при ошибке БД
try:
os.remove(file_path)
logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД")
except Exception as e:
logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}")
failed_count += 1
continue
processed_count += 1
logger.debug(f"add_in_db_media_mediagroup: Успешно обработано сообщение {i+1}/{len(sent_message)}")
except Exception as e:
logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}")
failed_count += 1
continue
processing_time = time.time() - start_time
if processed_count == 0:
logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}")
return False
if failed_count > 0:
logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}")
else:
logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с")
return failed_count == 0
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"add_in_db_media_mediagroup: Критическая ошибка обработки медиагруппы: {e}, время: {processing_time:.2f}с")
return False
@track_time("add_in_db_media", "helper_func")
@track_errors("helper_func", "add_in_db_media")
@track_media_processing("media_group")
@db_query_time("add_in_db_media", "posts", "insert")
@track_file_operations("media")
async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
"""
Добавляет контент одиночного сообщения в базу данных
Args: Args:
sent_message: sent_message объект из Telegram API sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных bot_db: Экземпляр базы данных
Returns: Returns:
Список InputFile (FSInputFile). bool: True если контент успешно добавлен, False в случае ошибки
""" """
start_time = time.time()
try:
# Валидация параметров
if not sent_message or not bot_db:
logger.error("add_in_db_media: Неверные параметры - sent_message или bot_db отсутствуют")
return False
post_id = sent_message.message_id # ID поста (это же сообщение)
content_type = None
file_id = None
# Определяем тип контента и file_id
if sent_message.photo: if sent_message.photo:
content_type = 'photo'
file_id = sent_message.photo[-1].file_id file_id = sent_message.photo[-1].file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'photo')
elif sent_message.video: elif sent_message.video:
content_type = 'video'
file_id = sent_message.video.file_id file_id = sent_message.video.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'video')
elif sent_message.voice: elif sent_message.voice:
content_type = 'voice'
file_id = sent_message.voice.file_id file_id = sent_message.voice.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'voice')
elif sent_message.audio: elif sent_message.audio:
content_type = 'audio'
file_id = sent_message.audio.file_id file_id = sent_message.audio.file_id
file_path = await download_file(sent_message, file_id=file_id)
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'audio')
elif sent_message.video_note: elif sent_message.video_note:
content_type = 'video_note'
file_id = sent_message.video_note.file_id file_id = sent_message.video_note.file_id
file_path = await download_file(sent_message, file_id=file_id) else:
bot_db.add_post_content_in_db(sent_message.message_id, sent_message.message_id, file_path, 'video_note') logger.warning(f"add_in_db_media: Неподдерживаемый тип контента для сообщения {post_id}")
return False
if not file_id:
logger.error(f"add_in_db_media: file_id отсутствует для сообщения {post_id}")
return False
logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}")
# Скачиваем файл
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type)
if not file_path:
logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}")
return False
# Добавляем в базу данных
success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type)
if not success:
logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}")
# Удаляем скачанный файл при ошибке БД
try:
os.remove(file_path)
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
except Exception as e:
logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}")
return False
processing_time = time.time() - start_time
logger.info(f"add_in_db_media: Контент успешно добавлен для сообщения {post_id}, тип: {content_type}, время: {processing_time:.2f}с")
return True
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"add_in_db_media: Ошибка обработки медиа для сообщения {post_id}: {e}, время: {processing_time:.2f}с")
return False
@track_time("send_media_group_message_to_private_chat", "helper_func")
@track_errors("helper_func", "send_media_group_message_to_private_chat")
@track_media_processing("media_group")
@db_query_time("send_media_group_message_to_private_chat", "posts", "insert")
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, 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: Any, main_post_id: Optional[int] = None) -> int:
sent_message = await message.bot.send_media_group( sent_message = await message.bot.send_media_group(
chat_id=chat_id, chat_id=chat_id,
media=media_group, media=media_group,
) )
bot_db.add_post_in_db(sent_message[-1].message_id, sent_message[-1].caption, message.from_user.id) post = TelegramPost(
await add_in_db_media_mediagroup(sent_message, bot_db) message_id=sent_message[-1].message_id,
text=sent_message[-1].caption or "",
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await bot_db.add_post(post)
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id)
if not success:
logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
message_id = sent_message[-1].message_id message_id = sent_message[-1].message_id
return message_id return message_id
@track_time("send_media_group_to_channel", "helper_func")
async def send_media_group_to_channel(bot, chat_id: int, post_content: list[tuple[str]], post_text: str): @track_errors("helper_func", "send_media_group_to_channel")
@track_media_processing("media_group")
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str):
""" """
Отправляет медиа-группу с подписью к последнему файлу. Отправляет медиа-группу с подписью к последнему файлу.
@@ -255,49 +476,70 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: list[tupl
post_content: Список кортежей с путями к файлам. post_content: Список кортежей с путями к файлам.
post_text: Текст подписи. post_text: Текст подписи.
""" """
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
media = [] media = []
for file_path in post_content: for i, file_path in enumerate(post_content):
try: try:
file = FSInputFile(path=file_path[0]) file = FSInputFile(path=file_path[0])
type = file_path[1] type = file_path[1]
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})")
if type == 'video': if type == 'video':
media.append(types.InputMediaVideo(media=file)) media.append(types.InputMediaVideo(media=file))
if type == 'photo': elif type == 'photo':
media.append(types.InputMediaPhoto(media=file)) media.append(types.InputMediaPhoto(media=file))
else:
logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Файл не найден: {file_path[0]}") logger.error(f"Файл не найден: {file_path[0]}")
return return
except Exception as e:
logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}")
return
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
# Добавляем подпись к последнему файлу # Добавляем подпись к последнему файлу
if media: if media:
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else "" safe_post_text = html.escape(str(post_text)) if post_text else ""
media[-1].caption = safe_post_text media[-1].caption = safe_post_text
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
try:
await bot.send_media_group(chat_id=chat_id, media=media) await bot.send_media_group(chat_id=chat_id, media=media)
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}")
except Exception as e:
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
raise
@track_time("send_text_message", "helper_func")
@track_errors("helper_func", "send_text_message")
async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None): async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None):
from .rate_limiter import send_with_rate_limit
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else "" safe_post_text = html.escape(str(post_text)) if post_text else ""
async def _send_message():
if markup is None: if markup is None:
sent_message = await message.bot.send_message( return await message.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=safe_post_text text=safe_post_text
) )
message_id = sent_message.message_id
return message_id
else: else:
sent_message = await message.bot.send_message( return await message.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=safe_post_text, text=safe_post_text,
reply_markup=markup reply_markup=markup
) )
message_id = sent_message.message_id
return message_id
sent_message = await send_with_rate_limit(_send_message, chat_id)
return sent_message.message_id
@track_time("send_photo_message", "helper_func")
@track_errors("helper_func", "send_photo_message")
async def send_photo_message(chat_id, message: types.Message, photo: str, post_text: str, async def send_photo_message(chat_id, message: types.Message, photo: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None): markup: types.ReplyKeyboardMarkup = None):
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
@@ -318,7 +560,8 @@ async def send_photo_message(chat_id, message: types.Message, photo: str, post_t
) )
return sent_message return sent_message
@track_time("send_video_message", "helper_func")
@track_errors("helper_func", "send_video_message")
async def send_video_message(chat_id, message: types.Message, video: str, post_text: str = "", async def send_video_message(chat_id, message: types.Message, video: str, post_text: str = "",
markup: types.ReplyKeyboardMarkup = None): markup: types.ReplyKeyboardMarkup = None):
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
@@ -339,7 +582,8 @@ async def send_video_message(chat_id, message: types.Message, video: str, post_t
) )
return sent_message return sent_message
@track_time("send_video_note_message", "helper_func")
@track_errors("helper_func", "send_video_note_message")
async def send_video_note_message(chat_id, message: types.Message, video_note: str, async def send_video_note_message(chat_id, message: types.Message, video_note: str,
markup: types.ReplyKeyboardMarkup = None): markup: types.ReplyKeyboardMarkup = None):
if markup is None: if markup is None:
@@ -355,7 +599,8 @@ async def send_video_note_message(chat_id, message: types.Message, video_note: s
) )
return sent_message return sent_message
@track_time("send_audio_message", "helper_func")
@track_errors("helper_func", "send_audio_message")
async def send_audio_message(chat_id, message: types.Message, audio: str, post_text: str, async def send_audio_message(chat_id, message: types.Message, audio: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None): markup: types.ReplyKeyboardMarkup = None):
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
@@ -377,36 +622,48 @@ async def send_audio_message(chat_id, message: types.Message, audio: str, post_t
return sent_message return sent_message
@track_time("send_voice_message", "helper_func")
@track_errors("helper_func", "send_voice_message")
async def send_voice_message(chat_id, message: types.Message, voice: str, async def send_voice_message(chat_id, message: types.Message, voice: str,
markup: types.ReplyKeyboardMarkup = None): markup: types.ReplyKeyboardMarkup = None):
from .rate_limiter import send_with_rate_limit
async def _send_voice():
if markup is None: if markup is None:
sent_message = await message.bot.send_voice( return await message.bot.send_voice(
chat_id=chat_id, chat_id=chat_id,
voice=voice voice=voice
) )
else: else:
sent_message = await message.bot.send_voice( return await message.bot.send_voice(
chat_id=chat_id, chat_id=chat_id,
voice=voice, voice=voice,
reply_markup=markup reply_markup=markup
) )
return sent_message
return await send_with_rate_limit(_send_voice, chat_id)
def check_access(user_id: int, bot_db): @track_time("check_access", "helper_func")
@track_errors("helper_func", "check_access")
@db_query_time("check_access", "users", "select")
async def check_access(user_id: int, bot_db):
"""Проверка прав на совершение действий""" """Проверка прав на совершение действий"""
return bot_db.is_admin(user_id) from logs.custom_logger import logger
result = await bot_db.is_admin(user_id)
logger.info(f"check_access: пользователь {user_id} - результат: {result}")
return result
def add_days_to_date(days: int): def add_days_to_date(days: int):
"""Прибавляет указанное количество дней к текущей дате и возвращает дату в формате DD-MM-YYYY.""" """Прибавляет указанное количество дней к текущей дате и возвращает UNIX timestamp."""
current_date = datetime.now() current_date = datetime.now()
future_date = current_date + timedelta(days=days) future_date = current_date + timedelta(days=days)
formatted_date = future_date.strftime("%d-%m-%Y") return int(future_date.timestamp())
return formatted_date
@track_time("get_banned_users_list", "helper_func")
def get_banned_users_list(offset: int, bot_db): @track_errors("helper_func", "get_banned_users_list")
@db_query_time("get_banned_users_list", "users", "select")
async def get_banned_users_list(offset: int, bot_db):
""" """
Возвращает сообщение со списком пользователей и словарь с ником + идентификатором Возвращает сообщение со списком пользователей и словарь с ником + идентификатором
@@ -418,22 +675,58 @@ def get_banned_users_list(offset: int, bot_db):
message - текст сообщения message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)] user_ids - лист кортежей [(user_name: user_id)]
""" """
users = bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset) users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset)
message = "Список заблокированных пользователей:\n" message = "Список заблокированных пользователей:\n"
for user in users: for user in users:
# Экранируем пользовательские данные для безопасного использования user_id, ban_reason, unban_date = user
safe_user_name = html.escape(str(user[0])) if user[0] else "Неизвестный пользователь" # Получаем имя пользователя из таблицы users
safe_ban_reason = html.escape(str(user[2])) if user[2] else "Причина не указана" username = await bot_db.get_username(user_id)
safe_unban_date = html.escape(str(user[3])) if user[3] else "Дата не указана" full_name = await bot_db.get_full_name_by_id(user_id)
safe_user_name = username or full_name or f"User_{user_id}"
message += f"Пользователь: {safe_user_name}\n" # Экранируем пользовательские данные для безопасного использования
message += f"Причина бана: {safe_ban_reason}\n" safe_user_name = html.escape(str(safe_user_name))
message += f"Дата разбана: {safe_unban_date}\n\n" safe_ban_reason = html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
# Форматируем дату разбана в человекочитаемый формат
if unban_date:
try:
# Предполагаем, что unban_date это UNIX timestamp
if isinstance(unban_date, (int, float)):
unban_datetime = datetime.fromtimestamp(unban_date)
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
elif isinstance(unban_date, str):
# Если это строка, попытаемся её обработать
try:
# Попробуем преобразовать строку в timestamp
timestamp = int(unban_date)
unban_datetime = datetime.fromtimestamp(timestamp)
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
except (ValueError, TypeError):
# Если не удалось, показываем как есть
safe_unban_date = html.escape(str(unban_date))
elif hasattr(unban_date, 'strftime'):
# Если это datetime объект
safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M")
else:
# Для всех остальных случаев
safe_unban_date = html.escape(str(unban_date))
except (ValueError, TypeError, OSError):
# В случае ошибки показываем исходное значение
safe_unban_date = html.escape(str(unban_date))
else:
safe_unban_date = "Дата не указана"
message += f"**Пользователь:** {safe_user_name}\n"
message += f"**Причина бана:** {safe_ban_reason}\n"
message += f"**Дата разбана:** {safe_unban_date}\n\n"
return message return message
@track_time("get_banned_users_buttons", "helper_func")
def get_banned_users_buttons(bot_db): @track_errors("helper_func", "get_banned_users_buttons")
@db_query_time("get_banned_users_buttons", "users", "select")
async def get_banned_users_buttons(bot_db):
""" """
Возвращает сообщение со списком пользователей и словарь с ником + идентификатором Возвращает сообщение со списком пользователей и словарь с ником + идентификатором
@@ -444,41 +737,67 @@ def get_banned_users_buttons(bot_db):
message - текст сообщения message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)] user_ids - лист кортежей [(user_name: user_id)]
""" """
users = bot_db.get_banned_users_from_db() users = await bot_db.get_banned_users_from_db()
user_ids = [] user_ids = []
for user in users: for user in users:
user_id, ban_reason, unban_date = user
# Получаем имя пользователя из таблицы users
username = await bot_db.get_username(user_id)
full_name = await bot_db.get_full_name_by_id(user_id)
safe_user_name = username or full_name or f"User_{user_id}"
# Экранируем user_name для безопасного использования # Экранируем user_name для безопасного использования
safe_user_name = html.escape(str(user[0])) if user[0] else "Неизвестный пользователь" safe_user_name = html.escape(str(safe_user_name))
user_ids.append((safe_user_name, user[1])) user_ids.append((safe_user_name, user_id))
return user_ids return user_ids
@track_time("delete_user_blacklist", "helper_func")
def delete_user_blacklist(user_id: int, bot_db): @track_errors("helper_func", "delete_user_blacklist")
return bot_db.delete_user_blacklist(user_id=user_id) @db_query_time("delete_user_blacklist", "users", "delete")
async def delete_user_blacklist(user_id: int, bot_db):
return await bot_db.delete_user_blacklist(user_id=user_id)
def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db): @track_time("check_username_and_full_name", "helper_func")
username_db, full_name_db = bot_db.get_username_and_full_name(user_id=user_id) @track_errors("helper_func", "check_username_and_full_name")
@db_query_time("check_username_and_full_name", "users", "select")
async def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db):
"""Проверяет, изменились ли username или full_name пользователя"""
try:
username_db = await bot_db.get_username(user_id)
full_name_db = await bot_db.get_full_name_by_id(user_id)
return username != username_db or full_name != full_name_db return username != username_db or full_name != full_name_db
except Exception as e:
logger.error(f"Ошибка при проверке username и full_name: {e}")
return False
@track_time("unban_notifier", "helper_func")
def unban_notifier(self): @track_errors("helper_func", "unban_notifier")
# Получение сегодняшней даты в формате DD-MM-YYYY @db_query_time("unban_notifier", "users", "select")
async def unban_notifier(bot, BotDB, GROUP_FOR_MESSAGE):
# Получение текущего UNIX timestamp
current_date = datetime.now() current_date = datetime.now()
today = current_date.strftime("%d-%m-%Y") current_timestamp = int(current_date.timestamp())
# Получение списка разблокированных пользователей # Получение списка разблокированных пользователей
unblocked_users = self.BotDB.get_users_for_unblock_today(today) unblocked_users = await BotDB.get_users_for_unblock_today(current_timestamp)
message = "Разблокированные пользователи:\n" message = "Разблокированные пользователи:\n"
for user_id, user_name in unblocked_users.items(): for user_id in unblocked_users:
# Получаем имя пользователя из таблицы users
username = await BotDB.get_username(user_id)
full_name = await BotDB.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
# Экранируем user_name для безопасного использования # Экранируем user_name для безопасного использования
safe_user_name = html.escape(str(user_name)) if user_name else "Неизвестный пользователь" safe_user_name = html.escape(str(user_name))
message += f"ID: {user_id}, Имя: {safe_user_name}\n" message += f"ID: {user_id}, Имя: {safe_user_name}\n"
# Отправка сообщения в канал # Отправка сообщения в канал
self.bot.send_message(self.GROUP_FOR_MESSAGE, message) await bot.send_message(GROUP_FOR_MESSAGE, message)
@track_time("update_user_info", "helper_func")
@track_errors("helper_func", "update_user_info")
@db_query_time("update_user_info", "users", "update")
async def update_user_info(source: str, message: types.Message): async def update_user_info(source: str, message: types.Message):
# Собираем данные # Собираем данные
full_name = message.from_user.full_name full_name = message.from_user.full_name
@@ -487,41 +806,61 @@ async def update_user_info(source: str, message: types.Message):
is_bot = message.from_user.is_bot is_bot = message.from_user.is_bot
language_code = message.from_user.language_code language_code = message.from_user.language_code
user_id = message.from_user.id user_id = message.from_user.id
current_date = datetime.now()
date = current_date.strftime("%Y-%m-%d %H:%M:%S")
# Выбираем эмодзю, пробегаемся циклом и смотрим что в базе такого еще не было
user_emoji = get_random_emoji()
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, user_emoji = await get_random_emoji()
date)
if not await BotDB.user_exists(user_id):
# Create User object with current timestamp
from database.models import User
current_timestamp = int(datetime.now().timestamp())
user = User(
user_id=user_id,
first_name=first_name,
full_name=full_name,
username=username,
is_bot=is_bot,
language_code=language_code,
emoji=user_emoji,
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
)
await BotDB.add_user(user)
else: else:
is_need_update = check_username_and_full_name(user_id, username, full_name, BotDB) is_need_update = await check_username_and_full_name(user_id, username, full_name, BotDB)
if is_need_update: if is_need_update:
BotDB.update_username_and_full_name(user_id, username, full_name) await BotDB.update_user_info(user_id, username, full_name)
if source != 'voice': if source != 'voice':
await message.answer( await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}") f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}")
await message.bot.send_message(chat_id=GROUP_FOR_LOGS, await message.bot.send_message(chat_id=GROUP_FOR_LOGS,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}') text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}')
sleep(1) sleep(1)
BotDB.update_date_for_user(date, user_id) await BotDB.update_user_date(user_id)
def check_user_emoji(message: types.Message): @track_time("check_user_emoji", "helper_func")
@track_errors("helper_func", "check_user_emoji")
@db_query_time("check_emoji_for_user", "users", "select")
async def check_user_emoji(message: types.Message):
user_id = message.from_user.id user_id = message.from_user.id
user_emoji = BotDB.check_emoji_for_user(user_id=user_id) user_emoji = await BotDB.get_user_emoji(user_id=user_id)
if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""): if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""):
user_emoji = get_random_emoji() user_emoji = await get_random_emoji()
BotDB.update_emoji_for_user(user_id=user_id, emoji=user_emoji) await BotDB.update_user_emoji(user_id=user_id, emoji=user_emoji)
return user_emoji return user_emoji
def get_random_emoji(): @track_time("get_random_emoji", "helper_func")
@track_errors("helper_func", "get_random_emoji")
@db_query_time("check_emoji", "users", "select")
async def get_random_emoji():
attempts = 0 attempts = 0
while attempts < 100: while attempts < 100:
user_emoji = random.choice(emoji_list) user_emoji = random.choice(emoji_list)
if not BotDB.check_emoji(user_emoji): if not await BotDB.check_emoji_exists(user_emoji):
return user_emoji return user_emoji
attempts += 1 attempts += 1
logger.error("Не удалось найти уникальный эмодзи после нескольких попыток.") logger.error("Не удалось найти уникальный эмодзи после нескольких попыток.")

View File

@@ -1,26 +1,31 @@
import html import html
# Local imports - metrics
from .metrics import (
metrics,
track_time,
track_errors
)
def get_message(username: str, type_message: str):
constants = { constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" 'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂"
"&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" "&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
"&Предлагай свой пост мне и я обязательно его опубликую😉" "&Предлагай свой пост мне и я обязательно его опубликую😉"
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
"&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." "&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
"&&Основная группа в ВК: https://vk.com/love_bsk" "&&Группа в ВК: https://vk.com/love_bsk"
"&Основной канал в ТГ: https://t.me/love_bsk", "&Канал в ТГ: https://t.me/love_bsk",
'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" 'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼"
"&В данный момент я работаю в тестовом режиме, поэтому к посту можно прикрепить не более одного фото и никаких аудио или видео👻" "&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&&Обещаю, я научусь их обрабатывать, но позже🤝🤖",
'SUGGEST_NEWS_2': "Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
"&&❗️❗️❗️Я обучен только на команды, указанные мной выше❗️❗️❗️👆" "&&❗️❗️Я обучен только на команды, указанные мной выше👆"
"&Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" "&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&Пост будет опубликован только в группе ТГ📩", "&Пост будет опубликован только в группе ТГ📩",
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
@@ -31,13 +36,34 @@ def get_message(username: str, type_message: str):
"USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.", "USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.",
"QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉", "QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉",
"SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊", "SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊",
# Voice handler messages
"MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣" "MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣"
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼" "&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣" "&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂" "&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤" "&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив." "&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
'WELCOME_MESSAGE': "<b>Привет.</b>",
'DESCRIPTION_MESSAGE': "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
'ANALOGY_MESSAGE': "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
'RULES_MESSAGE': "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
'ANONYMITY_MESSAGE': "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
'SUGGESTION_MESSAGE': "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help",
'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌",
'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗",
'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
'UNKNOWN_CONTENT_MESSAGE': "Я тебя не понимаю🤷‍♀️ запиши голосовое",
'RECORD_VOICE_MESSAGE': "Хорошо, теперь пришли мне свое голосовое сообщение"
} }
@track_time("get_message", "message_service")
@track_errors("message_service", "get_message")
def get_message(username: str, type_message: str):
if username is None: if username is None:
# Поведение ожидаемое тестами: TypeError при username=None # Поведение ожидаемое тестами: TypeError при username=None
raise TypeError("username is None") raise TypeError("username is None")

704
helper_bot/utils/metrics.py Normal file
View File

@@ -0,0 +1,704 @@
"""
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
import os
from functools import wraps
import asyncio
from contextlib import asynccontextmanager
# Метрики rate limiter теперь создаются в основном классе
class BotMetrics:
"""Central class for managing all bot metrics."""
def __init__(self):
self.registry = CollectorRegistry()
# Создаем метрики rate limiter в том же registry
self._create_rate_limit_metrics()
# 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
)
# Total users gauge (отдельная метрика)
self.total_users = Gauge(
'total_users',
'Total number of users in database',
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
)
# Database errors counter
self.db_errors_total = Counter(
'db_errors_total',
'Total number of database errors',
['error_type', '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', 'user_id', 'action'],
registry=self.registry
)
# User activity metrics
self.user_activity_total = Counter(
'user_activity_total',
'Total user activity events',
['activity_type', 'user_type', 'chat_type'],
registry=self.registry
)
# File download metrics
self.file_downloads_total = Counter(
'file_downloads_total',
'Total number of file downloads',
['content_type', 'status'],
registry=self.registry
)
self.file_download_duration_seconds = Histogram(
'file_download_duration_seconds',
'Time spent downloading files',
['content_type'],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
registry=self.registry
)
self.file_download_size_bytes = Histogram(
'file_download_size_bytes',
'Size of downloaded files in bytes',
['content_type'],
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
registry=self.registry
)
# Media processing metrics
self.media_processing_total = Counter(
'media_processing_total',
'Total number of media processing operations',
['content_type', 'status'],
registry=self.registry
)
self.media_processing_duration_seconds = Histogram(
'media_processing_duration_seconds',
'Time spent processing media',
['content_type'],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
registry=self.registry
)
def _create_rate_limit_metrics(self):
"""Создает метрики rate limiter в основном registry"""
try:
# Создаем метрики rate limiter в том же registry
self.rate_limit_requests_total = Counter(
'rate_limit_requests_total',
'Total number of rate limited requests',
['chat_id', 'status', 'error_type'],
registry=self.registry
)
self.rate_limit_errors_total = Counter(
'rate_limit_errors_total',
'Total number of rate limit errors',
['error_type', 'chat_id'],
registry=self.registry
)
self.rate_limit_wait_duration_seconds = Histogram(
'rate_limit_wait_duration_seconds',
'Time spent waiting due to rate limiting',
['chat_id'],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
registry=self.registry
)
self.rate_limit_active_chats = Gauge(
'rate_limit_active_chats',
'Number of active chats with rate limiting',
registry=self.registry
)
self.rate_limit_success_rate = Gauge(
'rate_limit_success_rate',
'Success rate of rate limited requests',
['chat_id'],
registry=self.registry
)
self.rate_limit_requests_per_minute = Gauge(
'rate_limit_requests_per_minute',
'Requests per minute',
['chat_id'],
registry=self.registry
)
self.rate_limit_total_requests = Gauge(
'rate_limit_total_requests',
'Total number of requests',
['chat_id'],
registry=self.registry
)
self.rate_limit_total_errors = Gauge(
'rate_limit_total_errors',
'Total number of errors',
['chat_id', 'error_type'],
registry=self.registry
)
self.rate_limit_avg_wait_time_seconds = Gauge(
'rate_limit_avg_wait_time_seconds',
'Average wait time in seconds',
['chat_id'],
registry=self.registry
)
except Exception as e:
# Логируем ошибку, но не прерываем инициализацию
import logging
logging.warning(f"Failed to create rate limit metrics: {e}")
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 = "daily"):
"""Set the number of active users for a specific type."""
self.active_users.labels(user_type=user_type).set(count)
def set_total_users(self, count: int):
"""Set the total number of users in database."""
self.total_users.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 record_file_download(self, content_type: str, file_size: int, duration: float):
"""Record file download metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="success"
).inc()
self.file_download_duration_seconds.labels(
content_type=content_type
).observe(duration)
self.file_download_size_bytes.labels(
content_type=content_type
).observe(file_size)
def record_file_download_error(self, content_type: str, error_message: str):
"""Record file download error metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="error"
).inc()
self.errors_total.labels(
error_type="file_download_error",
handler_type="media_processing",
method_name="download_file"
).inc()
def record_media_processing(self, content_type: str, duration: float, success: bool):
"""Record media processing metrics."""
status = "success" if success else "error"
self.media_processing_total.labels(
content_type=content_type,
status=status
).inc()
self.media_processing_duration_seconds.labels(
content_type=content_type
).observe(duration)
if not success:
self.errors_total.labels(
error_type="media_processing_error",
handler_type="media_processing",
method_name="add_in_db_media"
).inc()
def record_db_error(self, error_type: str, query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
"""Record database error occurrence."""
self.db_errors_total.labels(
error_type=error_type,
query_type=query_type,
table_name=table_name,
operation=operation
).inc()
def record_rate_limit_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: str = None):
"""Record rate limit request metrics."""
try:
# Определяем статус
status = "success" if success else "error"
# Записываем счетчик запросов
self.rate_limit_requests_total.labels(
chat_id=str(chat_id),
status=status,
error_type=error_type or "none"
).inc()
# Записываем время ожидания
if wait_time > 0:
self.rate_limit_wait_duration_seconds.labels(
chat_id=str(chat_id)
).observe(wait_time)
# Записываем ошибки
if not success and error_type:
self.rate_limit_errors_total.labels(
error_type=error_type,
chat_id=str(chat_id)
).inc()
except Exception as e:
import logging
logging.warning(f"Failed to record rate limit request: {e}")
def update_rate_limit_gauges(self):
"""Update rate limit gauge metrics."""
try:
from .rate_limit_monitor import rate_limit_monitor
# Обновляем количество активных чатов
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
# Обновляем метрики для каждого чата
for chat_id, chat_stats in rate_limit_monitor.stats.items():
chat_id_str = str(chat_id)
# Процент успеха
self.rate_limit_success_rate.labels(
chat_id=chat_id_str
).set(chat_stats.success_rate)
# Запросов в минуту
self.rate_limit_requests_per_minute.labels(
chat_id=chat_id_str
).set(chat_stats.requests_per_minute)
# Общее количество запросов
self.rate_limit_total_requests.labels(
chat_id=chat_id_str
).set(chat_stats.total_requests)
# Среднее время ожидания
self.rate_limit_avg_wait_time_seconds.labels(
chat_id=chat_id_str
).set(chat_stats.average_wait_time)
# Количество ошибок по типам
if chat_stats.retry_after_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="RetryAfter"
).set(chat_stats.retry_after_errors)
if chat_stats.other_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="Other"
).set(chat_stats.other_errors)
except Exception as e:
import logging
logging.warning(f"Failed to update rate limit gauges: {e}")
def get_metrics(self) -> bytes:
"""Generate metrics in Prometheus format."""
# Обновляем gauge метрики rate limiter перед генерацией
self.update_rate_limit_gauges()
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_db_error(
type(e).__name__,
query_type,
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_db_error(
type(e).__name__,
query_type,
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
def track_media_processing(content_type: str = "unknown"):
"""Decorator to track media processing operations."""
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_media_processing(content_type, duration, True)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, True)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_file_operations(content_type: str = "unknown"):
"""Decorator to track file download/upload operations."""
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
# Получаем размер файла из результата
file_size = 0
if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result)
# Записываем метрики
metrics.record_file_download(content_type, file_size, duration)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e))
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
# Получаем размер файла из результата
file_size = 0
if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result)
# Записываем метрики
metrics.record_file_download(content_type, file_size, duration)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e))
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator

View File

@@ -0,0 +1,220 @@
"""
Мониторинг и статистика rate limiting
"""
import time
from typing import Dict, List, Optional
from dataclasses import dataclass, field
from collections import defaultdict, deque
from logs.custom_logger import logger
@dataclass
class RateLimitStats:
"""Статистика rate limiting для чата"""
chat_id: int
total_requests: int = 0
successful_requests: int = 0
failed_requests: int = 0
retry_after_errors: int = 0
other_errors: int = 0
total_wait_time: float = 0.0
last_request_time: float = 0.0
request_times: deque = field(default_factory=lambda: deque(maxlen=100))
@property
def success_rate(self) -> float:
"""Процент успешных запросов"""
if self.total_requests == 0:
return 1.0
return self.successful_requests / self.total_requests
@property
def error_rate(self) -> float:
"""Процент ошибок"""
return 1.0 - self.success_rate
@property
def average_wait_time(self) -> float:
"""Среднее время ожидания"""
if self.total_requests == 0:
return 0.0
return self.total_wait_time / self.total_requests
@property
def requests_per_minute(self) -> float:
"""Запросов в минуту"""
if not self.request_times:
return 0.0
current_time = time.time()
minute_ago = current_time - 60
# Подсчитываем запросы за последнюю минуту
recent_requests = sum(1 for req_time in self.request_times if req_time > minute_ago)
return recent_requests
class RateLimitMonitor:
"""Монитор для отслеживания статистики rate limiting"""
def __init__(self, max_history_size: int = 1000):
self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0))
self.global_stats = RateLimitStats(0)
self.max_history_size = max_history_size
self.error_history: deque = deque(maxlen=max_history_size)
def record_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
"""Записывает информацию о запросе"""
current_time = time.time()
# Обновляем статистику для чата
chat_stats = self.stats[chat_id]
chat_stats.chat_id = chat_id
chat_stats.total_requests += 1
chat_stats.total_wait_time += wait_time
chat_stats.last_request_time = current_time
chat_stats.request_times.append(current_time)
if success:
chat_stats.successful_requests += 1
else:
chat_stats.failed_requests += 1
if error_type == "RetryAfter":
chat_stats.retry_after_errors += 1
else:
chat_stats.other_errors += 1
# Записываем ошибку в историю
self.error_history.append({
'chat_id': chat_id,
'error_type': error_type,
'timestamp': current_time,
'wait_time': wait_time
})
# Обновляем глобальную статистику
self.global_stats.total_requests += 1
self.global_stats.total_wait_time += wait_time
self.global_stats.last_request_time = current_time
self.global_stats.request_times.append(current_time)
if success:
self.global_stats.successful_requests += 1
else:
self.global_stats.failed_requests += 1
if error_type == "RetryAfter":
self.global_stats.retry_after_errors += 1
else:
self.global_stats.other_errors += 1
def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]:
"""Получает статистику для конкретного чата"""
return self.stats.get(chat_id)
def get_global_stats(self) -> RateLimitStats:
"""Получает глобальную статистику"""
return self.global_stats
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
"""Получает топ чатов по количеству запросов"""
sorted_chats = sorted(
self.stats.items(),
key=lambda x: x[1].total_requests,
reverse=True
)
return sorted_chats[:limit]
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
"""Получает чаты с высоким процентом ошибок"""
high_error_chats = [
(chat_id, stats) for chat_id, stats in self.stats.items()
if stats.error_rate > threshold and stats.total_requests > 5
]
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
def get_recent_errors(self, minutes: int = 60) -> List[dict]:
"""Получает недавние ошибки"""
current_time = time.time()
cutoff_time = current_time - (minutes * 60)
return [
error for error in self.error_history
if error['timestamp'] > cutoff_time
]
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
"""Получает сводку ошибок за указанный период"""
recent_errors = self.get_recent_errors(minutes)
error_summary = defaultdict(int)
for error in recent_errors:
error_summary[error['error_type']] += 1
return dict(error_summary)
def log_statistics(self, log_level: str = "info"):
"""Логирует текущую статистику"""
global_stats = self.get_global_stats()
log_message = (
f"Rate Limit Statistics:\n"
f" Total requests: {global_stats.total_requests}\n"
f" Success rate: {global_stats.success_rate:.2%}\n"
f" Error rate: {global_stats.error_rate:.2%}\n"
f" RetryAfter errors: {global_stats.retry_after_errors}\n"
f" Other errors: {global_stats.other_errors}\n"
f" Average wait time: {global_stats.average_wait_time:.2f}s\n"
f" Requests per minute: {global_stats.requests_per_minute:.1f}\n"
f" Active chats: {len(self.stats)}"
)
if log_level == "error":
logger.error(log_message)
elif log_level == "warning":
logger.warning(log_message)
else:
logger.info(log_message)
# Логируем чаты с высоким процентом ошибок
high_error_chats = self.get_chats_with_high_error_rate(0.2)
if high_error_chats:
logger.warning(f"Chats with high error rate (>20%): {len(high_error_chats)}")
for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5
logger.warning(f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})")
def reset_stats(self, chat_id: Optional[int] = None):
"""Сбрасывает статистику"""
if chat_id is None:
# Сбрасываем всю статистику
self.stats.clear()
self.global_stats = RateLimitStats(0)
self.error_history.clear()
else:
# Сбрасываем статистику для конкретного чата
if chat_id in self.stats:
del self.stats[chat_id]
# Глобальный экземпляр монитора
rate_limit_monitor = RateLimitMonitor()
def record_rate_limit_request(chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
"""Удобная функция для записи информации о запросе"""
rate_limit_monitor.record_request(chat_id, success, wait_time, error_type)
def get_rate_limit_summary() -> Dict:
"""Получает краткую сводку по rate limiting"""
global_stats = rate_limit_monitor.get_global_stats()
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
return {
'total_requests': global_stats.total_requests,
'success_rate': global_stats.success_rate,
'error_rate': global_stats.error_rate,
'recent_errors_count': len(recent_errors),
'active_chats': len(rate_limit_monitor.stats),
'requests_per_minute': global_stats.requests_per_minute,
'average_wait_time': global_stats.average_wait_time
}

View File

@@ -0,0 +1,215 @@
"""
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
"""
import asyncio
import time
from typing import Dict, Optional, Any, Callable
from dataclasses import dataclass
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
from logs.custom_logger import logger
from .metrics import metrics
@dataclass
class RateLimitConfig:
"""Конфигурация для rate limiting"""
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 3 # Максимум 3 сообщения подряд
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
max_retry_delay: float = 60.0 # Максимальная задержка между попытками
class ChatRateLimiter:
"""Rate limiter для конкретного чата"""
def __init__(self, config: RateLimitConfig):
self.config = config
self.last_send_time = 0.0
self.burst_count = 0
self.burst_reset_time = 0.0
self.retry_delay = 1.0
async def wait_if_needed(self) -> None:
"""Ждет если необходимо для соблюдения rate limit"""
current_time = time.time()
# Сбрасываем счетчик burst если прошло достаточно времени
if current_time >= self.burst_reset_time:
self.burst_count = 0
self.burst_reset_time = current_time + 1.0
# Проверяем burst limit
if self.burst_count >= self.config.burst_limit:
wait_time = self.burst_reset_time - current_time
if wait_time > 0:
logger.info(f"Burst limit reached, waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time)
current_time = time.time()
self.burst_count = 0
self.burst_reset_time = current_time + 1.0
# Проверяем минимальный интервал между сообщениями
time_since_last = current_time - self.last_send_time
min_interval = 1.0 / self.config.messages_per_second
if time_since_last < min_interval:
wait_time = min_interval - time_since_last
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time)
# Обновляем время последней отправки
self.last_send_time = time.time()
self.burst_count += 1
class GlobalRateLimiter:
"""Глобальный rate limiter для всех чатов"""
def __init__(self, config: RateLimitConfig):
self.config = config
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
self.global_last_send = 0.0
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
"""Получает rate limiter для конкретного чата"""
if chat_id not in self.chat_limiters:
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
return self.chat_limiters[chat_id]
async def wait_if_needed(self, chat_id: int) -> None:
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
current_time = time.time()
# Глобальный rate limit
time_since_global = current_time - self.global_last_send
if time_since_global < self.global_min_interval:
wait_time = self.global_min_interval - time_since_global
await asyncio.sleep(wait_time)
current_time = time.time()
# Чат-специфичный rate limit
chat_limiter = self.get_chat_limiter(chat_id)
await chat_limiter.wait_if_needed()
self.global_last_send = time.time()
class RetryHandler:
"""Обработчик повторных попыток с экспоненциальной задержкой"""
def __init__(self, config: RateLimitConfig):
self.config = config
async def execute_with_retry(
self,
func: Callable,
chat_id: int,
*args,
max_retries: int = 3,
**kwargs
) -> Any:
"""Выполняет функцию с повторными попытками при ошибках"""
retry_count = 0
current_delay = self.config.retry_after_multiplier
total_wait_time = 0.0
while retry_count <= max_retries:
try:
result = await func(*args, **kwargs)
# Записываем успешный запрос
metrics.record_rate_limit_request(chat_id, True, total_wait_time)
return result
except TelegramRetryAfter as e:
retry_count += 1
if retry_count > max_retries:
logger.error(f"Max retries exceeded for RetryAfter: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "RetryAfter")
raise
# Используем время ожидания от Telegram или наше увеличенное
wait_time = max(e.retry_after, current_delay)
wait_time = min(wait_time, self.config.max_retry_delay)
total_wait_time += wait_time
logger.warning(f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})")
await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier
except TelegramAPIError as e:
retry_count += 1
if retry_count > max_retries:
logger.error(f"Max retries exceeded for TelegramAPIError: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "TelegramAPIError")
raise
wait_time = min(current_delay, self.config.max_retry_delay)
total_wait_time += wait_time
logger.warning(f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}")
await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier
except Exception as e:
# Для других ошибок не делаем retry
logger.error(f"Non-retryable error: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "Other")
raise
class TelegramRateLimiter:
"""Основной класс для rate limiting в Telegram боте"""
def __init__(self, config: Optional[RateLimitConfig] = None):
self.config = config or RateLimitConfig()
self.global_limiter = GlobalRateLimiter(self.config)
self.retry_handler = RetryHandler(self.config)
async def send_with_rate_limit(
self,
send_func: Callable,
chat_id: int,
*args,
**kwargs
) -> Any:
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
async def _send():
await self.global_limiter.wait_if_needed(chat_id)
return await send_func(*args, **kwargs)
return await self.retry_handler.execute_with_retry(_send, chat_id)
# Глобальный экземпляр rate limiter
from helper_bot.config.rate_limit_config import get_rate_limit_config, RateLimitSettings
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
"""Создает RateLimitConfig из RateLimitSettings"""
return RateLimitConfig(
messages_per_second=settings.messages_per_second,
burst_limit=settings.burst_limit,
retry_after_multiplier=settings.retry_after_multiplier,
max_retry_delay=settings.max_retry_delay
)
# Получаем конфигурацию из настроек
_rate_limit_settings = get_rate_limit_config("production")
_default_config = _create_rate_limit_config(_rate_limit_settings)
telegram_rate_limiter = TelegramRateLimiter(_default_config)
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> Any:
"""
Удобная функция для отправки сообщений с rate limiting
Args:
send_func: Функция отправки (например, bot.send_message)
chat_id: ID чата
*args, **kwargs: Аргументы для функции отправки
Returns:
Результат выполнения функции отправки
"""
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)

View File

@@ -9,7 +9,6 @@ class StateUser(StatesGroup):
PRE_CHAT = State() PRE_CHAT = State()
PRE_BAN = State() PRE_BAN = State()
PRE_BAN_ID = State() PRE_BAN_ID = State()
PRE_BAN_FORWARD = State()
BAN_2 = State() BAN_2 = State()
BAN_3 = State() BAN_3 = State()
BAN_4 = State() BAN_4 = State()

View File

@@ -1,24 +1,44 @@
import datetime import datetime
import os import os
import sys
from loguru import logger from loguru import logger
logger = logger.bind(name='main_log') # Remove default handler
logger.remove()
# Получение сегодняшней даты для имени файла # Check if running in Docker/container
today = datetime.date.today().strftime('%Y-%m-%d') 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__)) current_dir = os.path.dirname(os.path.abspath(__file__))
if not os.path.exists(current_dir): if not os.path.exists(current_dir):
# Если не существует, создаем ее
os.makedirs(current_dir) os.makedirs(current_dir)
today = datetime.date.today().strftime('%Y-%m-%d')
filename = f'{current_dir}/helper_bot_{today}.log' filename = f'{current_dir}/helper_bot_{today}.log'
# Настройка формата логов
logger.add( logger.add(
filename, filename,
rotation="00:00", rotation="00:00",
retention="30 days", retention=f"{os.getenv('LOG_RETENTION_DAYS', '30')} days",
format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {name} | {line} | {message}", 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')

View File

@@ -1,38 +0,0 @@
import os
import sys
# Добавляем путь к корневой директории проекта
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from database.db import BotDB
# Получаем текущую директорию
current_dir = os.path.dirname(__file__)
# Переходим на уровень выше
parent_dir = os.path.dirname(current_dir)
BotDB = BotDB(parent_dir, 'tg-bot-database.db')
def get_filename():
"""Возвращает имя файла без расширения."""
filename = os.path.basename(__file__)
filename = os.path.splitext(filename)[0]
return filename
def main():
migrations_init = """
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY NOT NULL,
script_name TEXT NOT NULL,
created_at TEXT
);
"""
BotDB.create_table(migrations_init)
BotDB.update_version(0, get_filename())
if __name__ == "__main__":
main()

View File

@@ -1,67 +0,0 @@
import os
import sys
# Добавляем путь к корневой директории проекта
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from database.db import BotDB
# Получаем текущую директорию
current_dir = os.path.dirname(__file__)
# Переходим на уровень выше
parent_dir = os.path.dirname(current_dir)
BotDB = BotDB(parent_dir, 'tg-bot-database.db')
def get_filename():
"""Возвращает имя файла без расширения."""
filename = os.path.basename(__file__)
filename = os.path.splitext(filename)[0]
return filename
def main():
# Проверка версии миграций
current_version = BotDB.get_current_version() # Добавьте функцию для получения версии
# Выполнение миграций и проверка последней версии
if current_version < 1:
# Скрипты миграции
create_table_sql_1 = """
CREATE TABLE IF NOT EXISTS "admins" (
user_id INTEGER NOT NULL,
"role" TEXT
);
"""
create_table_sql_2 = """CREATE TABLE IF NOT EXISTS "blacklist"
(
"user_id" INTEGER NOT NULL UNIQUE,
"user_name" INTEGER,
"message_for_user" INTEGER,
"date_to_unban" INTEGER
);
"""
create_table_sql_3 = """
CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT,
user_id INTEGER,
message_id INTEGER NOT NULL,
date TEXT
);
"""
# Применение миграции
BotDB.create_table(create_table_sql_1)
BotDB.create_table(create_table_sql_2)
BotDB.create_table(create_table_sql_3)
BotDB.add_admin(842766148, 'creator')
BotDB.add_admin(920057022, 'admin')
filename = get_filename()
BotDB.update_version(1, filename)
if __name__ == "__main__":
main()

View File

@@ -1,65 +0,0 @@
import os
import sys
# Добавляем путь к корневой директории проекта
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from database.db import BotDB
# Получаем текущую директорию
current_dir = os.path.dirname(__file__)
# Переходим на уровень выше
parent_dir = os.path.dirname(current_dir)
BotDB = BotDB(parent_dir, 'tg-bot-database.db')
def get_filename():
"""Возвращает имя файла без расширения."""
filename = os.path.basename(__file__)
filename = os.path.splitext(filename)[0]
return filename
def main():
# Проверка версии миграций
current_version = BotDB.get_current_version() # Добавьте функцию для получения версии
# Выполнение миграций и проверка последней версии
if current_version < 2:
# Скрипты миграции
create_table_sql_1 = """
CREATE TABLE IF NOT EXISTS "post_from_telegram_suggest"
(
message_id INTEGER not null,
text TEXT,
helper_text_message_id INTEGER,
author_id INTEGER,
created_at TEXT
);
"""
create_table_sql_2 = """
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL
);
"""
create_table_sql_3 = """
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT
);
"""
# Применение миграции
BotDB.create_table(create_table_sql_1)
BotDB.create_table(create_table_sql_2)
BotDB.create_table(create_table_sql_3)
filename = get_filename()
BotDB.update_version(2, filename)
if __name__ == "__main__":
main()

View File

@@ -1,56 +0,0 @@
import os
import sys
# Добавляем путь к корневой директории проекта
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from database.db import BotDB
# Получаем текущую директорию
current_dir = os.path.dirname(__file__)
# Переходим на уровень выше
parent_dir = os.path.dirname(current_dir)
BotDB = BotDB(parent_dir, 'tg-bot-database.db')
def get_filename():
"""Возвращает имя файла без расширения."""
filename = os.path.basename(__file__)
filename = os.path.splitext(filename)[0]
return filename
def main():
# Проверка версии миграций
current_version = BotDB.get_current_version()
# Выполнение миграций и проверка последней версии
if current_version < 3:
# Скрипт миграции для создания таблицы our_users
create_table_sql = """
CREATE TABLE IF NOT EXISTS "our_users" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
first_name TEXT,
full_name TEXT,
username TEXT,
is_bot BOOLEAN DEFAULT 0,
language_code TEXT,
date_added TEXT,
date_changed TEXT,
has_stickers BOOLEAN DEFAULT 0
);
"""
# Применение миграции
BotDB.create_table(create_table_sql)
filename = get_filename()
BotDB.update_version(3, filename)
if __name__ == "__main__":
main()

30
pyproject.toml Normal file
View File

@@ -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"
]

View File

@@ -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

13
requirements-dev.txt Normal file
View File

@@ -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

View File

@@ -1,23 +1,30 @@
APScheduler==3.10.4 # Core dependencies
certifi~=2024.6.2 aiogram~=3.10.0
charset-normalizer==3.3.2 python-dotenv~=1.0.0
coverage==7.5.4
exceptiongroup==1.2.1 # Database
idna==3.7 aiosqlite~=0.20.0
iniconfig==2.0.0
# Logging
loguru==0.7.2 loguru==0.7.2
packaging==24.1
# System monitoring
psutil~=6.1.0
# Scheduling
apscheduler~=3.10.4
# Metrics and monitoring
prometheus-client==0.19.0
aiohttp==3.9.1
# Network stability improvements
aiohttp[speedups]>=3.9.1
aiodns>=3.0.0
cchardet>=2.1.7
# Development tools
pluggy==1.5.0 pluggy==1.5.0
pytest==8.2.2
pytz==2024.1
requests==2.32.3
six==1.16.0
tomli==2.0.1
tzlocal==5.2
urllib3~=2.2.1
pip~=23.2.1
attrs~=23.2.0 attrs~=23.2.0
typing_extensions~=4.12.2 typing_extensions~=4.12.2
aiohttp~=3.9.5 emoji~=2.8.0
aiogram~=3.10.0
emoji~=2.14.0

View File

@@ -1,6 +1,8 @@
import asyncio import asyncio
import os import os
import sys import sys
import signal
import sqlite3
# Ensure project root is on sys.path for module resolution # Ensure project root is on sys.path for module resolution
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -9,6 +11,137 @@ if CURRENT_DIR not in sys.path:
from helper_bot.main import start_bot from helper_bot.main import start_bot
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler
from logs.custom_logger import logger
async def main():
"""Основная функция запуска"""
bdf = get_global_instance()
# Создаем бота для автоматического разбана
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
auto_unban_bot = Bot(
token=bdf.settings['Telegram']['bot_token'],
default=DefaultBotProperties(parse_mode='HTML'),
timeout=30.0
)
# Инициализируем планировщик автоматического разбана
auto_unban_scheduler = get_auto_unban_scheduler()
auto_unban_scheduler.set_bot(auto_unban_bot)
auto_unban_scheduler.start_scheduler()
# Метрики запускаются в main.py через server_prometheus.py
# Здесь не нужно дублировать функциональность
# Флаг для корректного завершения
shutdown_event = asyncio.Event()
def signal_handler(signum, frame):
"""Обработчик сигналов для корректного завершения"""
logger.info(f"Получен сигнал {signum}, завершаем работу...")
shutdown_event.set()
# Регистрируем обработчики сигналов
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Запускаем бота (метрики запускаются внутри start_bot)
bot_task = asyncio.create_task(start_bot(bdf))
main_bot = None
try:
# Ждем сигнала завершения
await shutdown_event.wait()
logger.info("Начинаем корректное завершение...")
except KeyboardInterrupt:
logger.info("Получен сигнал завершения...")
finally:
logger.info("Останавливаем планировщик автоматического разбана...")
auto_unban_scheduler.stop_scheduler()
# Останавливаем планировщик метрик
try:
from helper_bot.utils.metrics_scheduler import stop_metrics_scheduler
stop_metrics_scheduler()
logger.info("Планировщик метрик остановлен")
except Exception as e:
logger.error(f"Ошибка при остановке планировщика метрик: {e}")
# Метрики останавливаются в main.py
logger.info("Останавливаем задачи...")
# Отменяем задачу бота
bot_task.cancel()
# Ждем завершения задачи бота и получаем результат main bot
try:
results = await asyncio.gather(bot_task, return_exceptions=True)
# Результат - это main bot
if results[0] and not isinstance(results[0], Exception):
main_bot = results[0]
except Exception as e:
logger.error(f"Ошибка при остановке задач: {e}")
# Закрываем сессию основного бота (если она еще не закрыта)
if main_bot and hasattr(main_bot, 'session') and not main_bot.session.closed:
try:
await main_bot.session.close()
logger.info("Сессия основного бота корректно закрыта")
except Exception as e:
logger.error(f"Ошибка при закрытии сессии основного бота: {e}")
# Закрываем сессию бота для автоматического разбана
if not auto_unban_bot.session.closed:
try:
await auto_unban_bot.session.close()
logger.info("Сессия бота автоматического разбана корректно закрыта")
except Exception as e:
logger.error(f"Ошибка при закрытии сессии бота автоматического разбана: {e}")
# Даем время на завершение всех aiohttp соединений
await asyncio.sleep(0.2)
logger.info("Бот корректно остановлен")
def init_db():
db_path = '/app/database/tg-bot-database.db'
schema_path = '/app/database/schema.sql'
if not os.path.exists(db_path):
print("Initializing database...")
with open(schema_path, 'r') as f:
schema = f.read()
with sqlite3.connect(db_path) as conn:
conn.executescript(schema)
print("Database initialized successfully")
if __name__ == '__main__': if __name__ == '__main__':
asyncio.run(start_bot(get_global_instance())) try:
init_db()
asyncio.run(main())
except AttributeError:
# Fallback for Python 3.6-3.7
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main())
finally:
# Закрываем все pending tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Ждем завершения всех задач
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()

103
scripts/voice_cleanup.py Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Скрипт для диагностики и очистки проблем с голосовыми файлами
"""
import asyncio
import sys
import os
from pathlib import Path
# Добавляем корневую директорию проекта в путь
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from database.async_db import AsyncBotDB
from helper_bot.handlers.voice.cleanup_utils import VoiceFileCleanupUtils
from logs.custom_logger import logger
async def main():
"""Основная функция скрипта"""
try:
# Инициализация базы данных
db_path = "database/tg-bot-database.db"
if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}")
return
bot_db = AsyncBotDB(db_path)
cleanup_utils = VoiceFileCleanupUtils(bot_db)
print("=== Диагностика голосовых файлов ===")
# Запускаем полную диагностику
diagnostic_result = await cleanup_utils.run_full_diagnostic()
print(f"\n📊 Статистика диска:")
if "error" in diagnostic_result["disk_stats"]:
print(f" ❌ Ошибка: {diagnostic_result['disk_stats']['error']}")
else:
stats = diagnostic_result["disk_stats"]
print(f" 📁 Директория: {stats['directory']}")
print(f" 📄 Всего файлов: {stats['total_files']}")
print(f" 💾 Размер: {stats['total_size_mb']} MB")
print(f"\n🗄️ База данных:")
print(f" 📝 Записей в БД: {diagnostic_result['db_records_count']}")
print(f" 🔍 Записей без файлов: {diagnostic_result['orphaned_db_records_count']}")
print(f" 📁 Файлов без записей: {diagnostic_result['orphaned_files_count']}")
print(f"\n📋 Статус: {diagnostic_result['status']}")
if diagnostic_result['status'] == 'issues_found':
print("\n⚠️ Найдены проблемы!")
if diagnostic_result['orphaned_db_records_count'] > 0:
print(f"\n🗑️ Записи в БД без файлов (первые 10):")
for file_name, user_id in diagnostic_result['orphaned_db_records']:
print(f" - {file_name} (user_id: {user_id})")
if diagnostic_result['orphaned_files_count'] > 0:
print(f"\n📁 Файлы без записей в БД (первые 10):")
for file_path in diagnostic_result['orphaned_files']:
print(f" - {file_path}")
# Предлагаем очистку
print("\n🧹 Хотите выполнить очистку?")
print("1. Удалить записи в БД без файлов")
print("2. Удалить файлы без записей в БД")
print("3. Выполнить полную очистку")
print("4. Выход")
choice = input("\nВыберите действие (1-4): ").strip()
if choice == "1":
print("\n🗑️ Удаление записей в БД без файлов...")
deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False)
print(f"✅ Удалено {deleted} записей")
elif choice == "2":
print("\n📁 Удаление файлов без записей в БД...")
deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False)
print(f"✅ Удалено {deleted} файлов")
elif choice == "3":
print("\n🧹 Полная очистка...")
db_deleted = await cleanup_utils.cleanup_orphaned_db_records(dry_run=False)
files_deleted = await cleanup_utils.cleanup_orphaned_files(dry_run=False)
print(f"✅ Удалено {db_deleted} записей в БД и {files_deleted} файлов")
elif choice == "4":
print("👋 Выход...")
else:
print("❌ Неверный выбор")
else:
print("\n✅ Проблем не найдено!")
except Exception as e:
logger.error(f"Ошибка в скрипте: {e}")
print(f"❌ Ошибка: {e}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -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

150
test_rate_limiting.py Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Скрипт для тестирования rate limiting решения
"""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock
from aiogram.types import Message, User, Chat
from helper_bot.utils.rate_limiter import send_with_rate_limit
from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary
async def test_rate_limiting():
"""Тестирует rate limiting с имитацией отправки сообщений"""
print("🚀 Начинаем тестирование rate limiting...")
# Создаем мок объекты
mock_bot = MagicMock()
mock_user = User(id=123, is_bot=False, first_name="Test")
mock_chat = Chat(id=456, type="private")
# Создаем Message с bot в конструкторе
mock_message = Message(
message_id=1,
date=int(time.time()),
chat=mock_chat,
from_user=mock_user,
content_type="text",
bot=mock_bot
)
# Настраиваем мок для send_voice
mock_bot.send_voice = AsyncMock(return_value=MagicMock(message_id=1))
# Функция для отправки голосового сообщения
async def send_voice_test():
return await mock_bot.send_voice(
chat_id=mock_chat.id,
voice="test_voice_id"
)
print("📊 Отправляем 5 сообщений подряд...")
# Отправляем несколько сообщений подряд
start_time = time.time()
for i in range(5):
print(f" Отправка сообщения {i+1}/5...")
try:
result = await send_with_rate_limit(send_voice_test, mock_chat.id)
print(f" ✅ Сообщение {i+1} отправлено успешно")
except Exception as e:
print(f" ❌ Ошибка при отправке сообщения {i+1}: {e}")
end_time = time.time()
total_time = end_time - start_time
print(f"\n⏱️ Общее время выполнения: {total_time:.2f} секунд")
print(f"📈 Среднее время на сообщение: {total_time/5:.2f} секунд")
# Показываем статистику
print("\n📊 Статистика rate limiting:")
summary = get_rate_limit_summary()
for key, value in summary.items():
if isinstance(value, float):
print(f" {key}: {value:.2f}")
else:
print(f" {key}: {value}")
# Показываем детальную статистику
print("\n🔍 Детальная статистика:")
global_stats = rate_limit_monitor.get_global_stats()
print(f" Всего запросов: {global_stats.total_requests}")
print(f" Успешных: {global_stats.successful_requests}")
print(f" Неудачных: {global_stats.failed_requests}")
print(f" Процент успеха: {global_stats.success_rate:.1%}")
print(f" Среднее время ожидания: {global_stats.average_wait_time:.2f}с")
# Проверяем что rate limiting работает
if total_time > 8: # Должно занять больше 8 секунд (5 сообщений * 1.6с минимум)
print("\n✅ Rate limiting работает корректно - сообщения отправляются с задержкой")
else:
print("\n⚠️ Rate limiting может работать некорректно - сообщения отправлены слишком быстро")
print("\n🎉 Тестирование завершено!")
async def test_error_handling():
"""Тестирует обработку ошибок"""
print("\n🧪 Тестируем обработку ошибок...")
# Создаем мок который будет падать с RetryAfter
from aiogram.exceptions import TelegramRetryAfter
mock_bot = MagicMock()
mock_chat = Chat(id=789, type="private")
call_count = 0
async def failing_send():
nonlocal call_count
call_count += 1
if call_count <= 2:
raise TelegramRetryAfter(
method=MagicMock(),
message="Flood control exceeded",
retry_after=1
)
return MagicMock(message_id=call_count)
mock_bot.send_voice = failing_send
print("📤 Отправляем сообщение с имитацией RetryAfter ошибки...")
start_time = time.time()
try:
result = await send_with_rate_limit(failing_send, mock_chat.id)
end_time = time.time()
print(f"✅ Сообщение отправлено после {call_count} попыток за {end_time - start_time:.2f}с")
except Exception as e:
print(f"❌ Ошибка: {e}")
print("🎯 Тест обработки ошибок завершен!")
async def main():
"""Основная функция"""
print("🔧 Тестирование решения Flood Control")
print("=" * 50)
# Сбрасываем статистику
rate_limit_monitor.reset_stats()
# Запускаем тесты
await test_rate_limiting()
await test_error_handling()
print("\n" + "=" * 50)
print("📋 Итоговая статистика:")
summary = get_rate_limit_summary()
for key, value in summary.items():
if isinstance(value, float):
print(f" {key}: {value:.2f}")
else:
print(f" {key}: {value}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -6,11 +6,14 @@ from unittest.mock import Mock, AsyncMock, patch
from aiogram.types import Message, User, Chat from aiogram.types import Message, User, Chat
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from database.db import BotDB from database.async_db import AsyncBotDB
# Импортируем моки в самом начале # Импортируем моки в самом начале
import tests.mocks import tests.mocks
# Настройка pytest-asyncio
pytest_plugins = ('pytest_asyncio',)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def event_loop(): def event_loop():
@@ -55,15 +58,15 @@ def mock_state():
@pytest.fixture @pytest.fixture
def mock_db(): def mock_db():
"""Создает мок базы данных для тестов""" """Создает мок базы данных для тестов"""
db = Mock(spec=BotDB) db = Mock(spec=AsyncBotDB)
db.user_exists = Mock(return_value=False) db.user_exists = Mock(return_value=False)
db.add_new_user_in_db = Mock() db.add_new_user = Mock()
db.update_date_for_user = Mock() db.update_user_date = Mock()
db.update_username_and_full_name = Mock() db.update_user_info = Mock()
db.add_post_in_db = Mock() db.add_post_in_db = Mock()
db.update_info_about_stickers = Mock() db.update_stickers_info = Mock()
db.add_new_message_in_db = Mock() db.add_new_message_in_db = Mock()
db.get_info_about_stickers = Mock(return_value=False) db.get_stickers_info = Mock(return_value=False)
db.get_username_and_full_name = Mock(return_value=("testuser", "Test User")) db.get_username_and_full_name = Mock(return_value=("testuser", "Test User"))
return db return db

View File

@@ -0,0 +1,125 @@
import pytest
import tempfile
import os
from datetime import datetime
from database.repositories.message_repository import MessageRepository
from database.models import UserMessage
@pytest.fixture(scope="session")
def test_db_path():
"""Фикстура для пути к тестовой БД (сессионная область)."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
temp_path = f.name
yield temp_path
# Очистка после всех тестов
try:
os.unlink(temp_path)
except OSError:
pass
@pytest.fixture
def message_repository(test_db_path):
"""Фикстура для MessageRepository."""
return MessageRepository(test_db_path)
@pytest.fixture
def sample_messages():
"""Фикстура для набора тестовых сообщений."""
base_timestamp = int(datetime.now().timestamp())
return [
UserMessage(
message_text="Первое тестовое сообщение",
user_id=1001,
telegram_message_id=2001,
date=base_timestamp
),
UserMessage(
message_text="Второе тестовое сообщение",
user_id=1002,
telegram_message_id=2002,
date=base_timestamp + 1
),
UserMessage(
message_text="Третье тестовое сообщение",
user_id=1003,
telegram_message_id=2003,
date=base_timestamp + 2
)
]
@pytest.fixture
def message_without_date():
"""Фикстура для сообщения без даты."""
return UserMessage(
message_text="Сообщение без даты",
user_id=1004,
telegram_message_id=2004,
date=None
)
@pytest.fixture
def message_with_zero_date():
"""Фикстура для сообщения с нулевой датой."""
return UserMessage(
message_text="Сообщение с нулевой датой",
user_id=1005,
telegram_message_id=2005,
date=0
)
@pytest.fixture
def message_with_special_chars():
"""Фикстура для сообщения со специальными символами."""
return UserMessage(
message_text="Сообщение с 'кавычками', \"двойными кавычками\" и эмодзи 😊\nНовая строка",
user_id=1006,
telegram_message_id=2006,
date=int(datetime.now().timestamp())
)
@pytest.fixture
def long_message():
"""Фикстура для длинного сообщения."""
long_text = "Очень длинное сообщение " * 100 # ~2400 символов
return UserMessage(
message_text=long_text,
user_id=1007,
telegram_message_id=2007,
date=int(datetime.now().timestamp())
)
@pytest.fixture
def message_with_unicode():
"""Фикстура для сообщения с Unicode символами."""
return UserMessage(
message_text="Сообщение с Unicode: 你好世界 🌍 Привет мир",
user_id=1008,
telegram_message_id=2008,
date=int(datetime.now().timestamp())
)
@pytest.fixture
async def initialized_repository(message_repository):
"""Фикстура для инициализированного репозитория с созданными таблицами."""
await message_repository.create_tables()
return message_repository
@pytest.fixture
async def repository_with_data(initialized_repository, sample_messages):
"""Фикстура для репозитория с тестовыми данными."""
for message in sample_messages:
await initialized_repository.add_message(message)
return initialized_repository

View File

@@ -0,0 +1,208 @@
import pytest
import asyncio
import os
import tempfile
from datetime import datetime
from unittest.mock import Mock, AsyncMock
from database.repositories.post_repository import PostRepository
from database.models import TelegramPost, PostContent, MessageContentLink
@pytest.fixture(scope="session")
def event_loop():
"""Создает event loop для асинхронных тестов"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
def mock_post_repository():
"""Создает мок PostRepository для unit тестов"""
mock_repo = Mock(spec=PostRepository)
mock_repo._execute_query = AsyncMock()
mock_repo._execute_query_with_result = AsyncMock()
mock_repo.logger = Mock()
return mock_repo
@pytest.fixture
def sample_telegram_post():
"""Создает тестовый объект TelegramPost"""
return TelegramPost(
message_id=12345,
text="Тестовый пост для unit тестов",
author_id=67890,
helper_text_message_id=None,
created_at=int(datetime.now().timestamp())
)
@pytest.fixture
def sample_telegram_post_with_helper():
"""Создает тестовый объект TelegramPost с helper сообщением"""
return TelegramPost(
message_id=12346,
text="Тестовый пост с helper сообщением",
author_id=67890,
helper_text_message_id=99999,
created_at=int(datetime.now().timestamp())
)
@pytest.fixture
def sample_telegram_post_no_date():
"""Создает тестовый объект TelegramPost без даты"""
return TelegramPost(
message_id=12347,
text="Тестовый пост без даты",
author_id=67890,
helper_text_message_id=None,
created_at=None
)
@pytest.fixture
def sample_post_content():
"""Создает тестовый объект PostContent"""
return PostContent(
message_id=12345,
content_name="/path/to/test/file.jpg",
content_type="photo"
)
@pytest.fixture
def sample_message_content_link():
"""Создает тестовый объект MessageContentLink"""
return MessageContentLink(
post_id=12345,
message_id=67890
)
@pytest.fixture
def mock_db_execute_query():
"""Создает мок для _execute_query"""
return AsyncMock()
@pytest.fixture
def mock_db_execute_query_with_result():
"""Создает мок для _execute_query_with_result"""
return AsyncMock()
@pytest.fixture
def mock_logger():
"""Создает мок для logger"""
return Mock()
@pytest.fixture
def temp_db_file():
"""Создает временный файл БД для интеграционных тестов"""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp_file:
db_path = tmp_file.name
yield db_path
# Очищаем временный файл после тестов
try:
os.unlink(db_path)
except OSError:
pass
@pytest.fixture
def real_post_repository(temp_db_file):
"""Создает реальный PostRepository с временной БД для интеграционных тестов"""
return PostRepository(temp_db_file)
@pytest.fixture
def sample_posts_batch():
"""Создает набор тестовых постов для batch тестов"""
return [
TelegramPost(
message_id=10001,
text="Первый тестовый пост",
author_id=11111,
helper_text_message_id=None,
created_at=int(datetime.now().timestamp())
),
TelegramPost(
message_id=10002,
text="Второй тестовый пост",
author_id=22222,
helper_text_message_id=None,
created_at=int(datetime.now().timestamp())
),
TelegramPost(
message_id=10003,
text="Третий тестовый пост",
author_id=33333,
helper_text_message_id=88888,
created_at=int(datetime.now().timestamp())
)
]
@pytest.fixture
def sample_content_batch():
"""Создает набор тестового контента для batch тестов"""
return [
(10001, "/path/to/photo1.jpg", "photo"),
(10002, "/path/to/video1.mp4", "video"),
(10003, "/path/to/audio1.mp3", "audio"),
(10004, "/path/to/photo2.jpg", "photo"),
(10005, "/path/to/video2.mp4", "video")
]
@pytest.fixture
def mock_database_connection():
"""Создает мок для DatabaseConnection"""
mock_conn = Mock()
mock_conn._execute_query = AsyncMock()
mock_conn._execute_query_with_result = AsyncMock()
mock_conn.logger = Mock()
return mock_conn
@pytest.fixture
def sample_helper_message_ids():
"""Создает набор тестовых helper message ID"""
return [11111, 22222, 33333, 44444, 55555]
@pytest.fixture
def sample_message_ids():
"""Создает набор тестовых message ID"""
return [10001, 10002, 10003, 10004, 10005]
@pytest.fixture
def sample_author_ids():
"""Создает набор тестовых author ID"""
return [11111, 22222, 33333, 44444, 55555]
@pytest.fixture
def mock_sql_queries():
"""Создает мок для SQL запросов"""
return {
'create_tables': [
"CREATE TABLE IF NOT EXISTS post_from_telegram_suggest",
"CREATE TABLE IF NOT EXISTS content_post_from_telegram",
"CREATE TABLE IF NOT EXISTS message_link_to_content"
],
'add_post': "INSERT INTO post_from_telegram_suggest",
'update_helper': "UPDATE post_from_telegram_suggest SET helper_text_message_id",
'add_content': "INSERT OR IGNORE INTO content_post_from_telegram",
'add_link': "INSERT OR IGNORE INTO message_link_to_content",
'get_content': "SELECT cpft.content_name, cpft.content_type",
'get_text': "SELECT text FROM post_from_telegram_suggest",
'get_ids': "SELECT mltc.message_id",
'get_author': "SELECT author_id FROM post_from_telegram_suggest"
}

View File

@@ -8,45 +8,35 @@ from unittest.mock import Mock, patch
# Патчим загрузку настроек до импорта модулей # Патчим загрузку настроек до импорта модулей
def setup_test_mocks(): def setup_test_mocks():
"""Настройка моков для тестов""" """Настройка моков для тестов"""
# Мокаем ConfigParser # Мокаем os.getenv
mock_config = Mock() mock_env_vars = {
'BOT_TOKEN': 'test_token_123',
def mock_getitem(section): 'LISTEN_BOT_TOKEN': '',
if section == 'Telegram': 'TEST_BOT_TOKEN': '',
return { 'PREVIEW_LINK': 'False',
'bot_token': 'test_token_123', 'MAIN_PUBLIC': '@test',
'preview_link': 'False', 'GROUP_FOR_POSTS': '-1001234567890',
'main_public': '@test', 'GROUP_FOR_MESSAGE': '-1001234567891',
'group_for_posts': '-1001234567890', 'GROUP_FOR_LOGS': '-1001234567893',
'group_for_message': '-1001234567891', 'IMPORTANT_LOGS': '-1001234567894',
'group_for_logs': '-1001234567893', 'TEST_GROUP': '-1001234567895',
'important_logs': '-1001234567894', 'LOGS': 'True',
'test_channel': '-1001234567895' 'TEST': 'False',
'DATABASE_PATH': 'database/test.db'
} }
elif section == 'Settings':
return {
'logs': 'True',
'test': 'False'
}
return {}
# Создаем MagicMock для поддержки __getitem__ def mock_getenv(key, default=None):
mock_config_instance = Mock() return mock_env_vars.get(key, default)
mock_config_instance.sections.return_value = ['Telegram', 'Settings']
mock_config_instance.__getitem__ = Mock(side_effect=mock_getitem)
mock_config.return_value = mock_config_instance env_patcher = patch('os.getenv', side_effect=mock_getenv)
env_patcher.start()
# Применяем патчи # Мокаем AsyncBotDB
config_patcher = patch('helper_bot.utils.base_dependency_factory.configparser.ConfigParser', mock_config)
config_patcher.start()
# Мокаем BotDB
mock_db = Mock() mock_db = Mock()
db_patcher = patch('helper_bot.utils.base_dependency_factory.BotDB', mock_db) db_patcher = patch('helper_bot.utils.base_dependency_factory.AsyncBotDB', mock_db)
db_patcher.start() 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()

View File

@@ -0,0 +1,295 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import time
from database.repositories.admin_repository import AdminRepository
from database.models import Admin
class TestAdminRepository:
"""Тесты для AdminRepository"""
@pytest.fixture
def mock_db_connection(self):
"""Мок для DatabaseConnection"""
mock_connection = Mock()
mock_connection._execute_query = AsyncMock()
mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock()
return mock_connection
@pytest.fixture
def admin_repository(self, mock_db_connection):
"""Экземпляр AdminRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(AdminRepository, '__init__', return_value=None):
repo = AdminRepository()
repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result
repo.logger = mock_db_connection.logger
return repo
@pytest.fixture
def sample_admin(self):
"""Тестовый администратор"""
return Admin(
user_id=12345,
role="admin"
)
@pytest.fixture
def sample_admin_with_created_at(self):
"""Тестовый администратор с датой создания"""
return Admin(
user_id=12345,
role="admin",
created_at="1705312200" # UNIX timestamp
)
@pytest.mark.asyncio
async def test_create_tables(self, admin_repository):
"""Тест создания таблицы администраторов"""
await admin_repository.create_tables()
# Проверяем, что включены внешние ключи
admin_repository._execute_query.assert_called()
calls = admin_repository._execute_query.call_args_list
# Первый вызов должен быть для включения внешних ключей
assert calls[0][0][0] == "PRAGMA foreign_keys = ON"
# Второй вызов должен быть для создания таблицы
create_table_call = calls[1]
assert "CREATE TABLE IF NOT EXISTS admins" in create_table_call[0][0]
assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0]
assert "role TEXT DEFAULT 'admin'" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0]
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0]
# Проверяем логирование
admin_repository.logger.info.assert_called_once_with("Таблица администраторов создана")
@pytest.mark.asyncio
async def test_add_admin(self, admin_repository, sample_admin):
"""Тест добавления администратора"""
await admin_repository.add_admin(sample_admin)
# Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query.assert_called_once()
call_args = admin_repository._execute_query.call_args
assert call_args[0][0] == "INSERT INTO admins (user_id, role) VALUES (?, ?)"
assert call_args[0][1] == (12345, "admin")
# Проверяем логирование
admin_repository.logger.info.assert_called_once_with(
"Администратор добавлен: user_id=12345, role=admin"
)
@pytest.mark.asyncio
async def test_add_admin_with_custom_role(self, admin_repository):
"""Тест добавления администратора с кастомной ролью"""
admin = Admin(user_id=67890, role="super_admin")
await admin_repository.add_admin(admin)
call_args = admin_repository._execute_query.call_args
assert call_args[0][1] == (67890, "super_admin")
admin_repository.logger.info.assert_called_once_with(
"Администратор добавлен: user_id=67890, role=super_admin"
)
@pytest.mark.asyncio
async def test_remove_admin(self, admin_repository):
"""Тест удаления администратора"""
user_id = 12345
await admin_repository.remove_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query.assert_called_once()
call_args = admin_repository._execute_query.call_args
assert call_args[0][0] == "DELETE FROM admins WHERE user_id = ?"
assert call_args[0][1] == (user_id,)
# Проверяем логирование
admin_repository.logger.info.assert_called_once_with(
"Администратор удален: user_id=12345"
)
@pytest.mark.asyncio
async def test_is_admin_true(self, admin_repository):
"""Тест проверки администратора - пользователь является администратором"""
user_id = 12345
# Мокаем результат запроса - пользователь найден
admin_repository._execute_query_with_result.return_value = [(1,)]
result = await admin_repository.is_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query_with_result.assert_called_once()
call_args = admin_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT 1 FROM admins WHERE user_id = ?"
assert call_args[0][1] == (user_id,)
# Проверяем результат
assert result is True
@pytest.mark.asyncio
async def test_is_admin_false(self, admin_repository):
"""Тест проверки администратора - пользователь не является администратором"""
user_id = 12345
# Мокаем результат запроса - пользователь не найден
admin_repository._execute_query_with_result.return_value = []
result = await admin_repository.is_admin(user_id)
# Проверяем результат
assert result is False
@pytest.mark.asyncio
async def test_get_admin_found(self, admin_repository):
"""Тест получения информации об администраторе - администратор найден"""
user_id = 12345
# Мокаем результат запроса
admin_repository._execute_query_with_result.return_value = [
(12345, "admin", "1705312200")
]
result = await admin_repository.get_admin(user_id)
# Проверяем, что метод вызван с правильными параметрами
admin_repository._execute_query_with_result.assert_called_once()
call_args = admin_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, role, created_at FROM admins WHERE user_id = ?"
assert call_args[0][1] == (user_id,)
# Проверяем результат
assert result is not None
assert result.user_id == 12345
assert result.role == "admin"
assert result.created_at == "1705312200"
@pytest.mark.asyncio
async def test_get_admin_not_found(self, admin_repository):
"""Тест получения информации об администраторе - администратор не найден"""
user_id = 12345
# Мокаем результат запроса - администратор не найден
admin_repository._execute_query_with_result.return_value = []
result = await admin_repository.get_admin(user_id)
# Проверяем результат
assert result is None
@pytest.mark.asyncio
async def test_get_admin_without_created_at(self, admin_repository):
"""Тест получения информации об администраторе без даты создания"""
user_id = 12345
# Мокаем результат запроса без created_at
admin_repository._execute_query_with_result.return_value = [
(12345, "admin")
]
result = await admin_repository.get_admin(user_id)
# Проверяем результат
assert result is not None
assert result.user_id == 12345
assert result.role == "admin"
assert result.created_at is None
@pytest.mark.asyncio
async def test_add_admin_error_handling(self, admin_repository, sample_admin):
"""Тест обработки ошибок при добавлении администратора"""
# Мокаем ошибку при выполнении запроса
admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await admin_repository.add_admin(sample_admin)
@pytest.mark.asyncio
async def test_remove_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при удалении администратора"""
# Мокаем ошибку при выполнении запроса
admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await admin_repository.remove_admin(12345)
@pytest.mark.asyncio
async def test_is_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при проверке администратора"""
# Мокаем ошибку при выполнении запроса
admin_repository._execute_query_with_result.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await admin_repository.is_admin(12345)
@pytest.mark.asyncio
async def test_get_admin_error_handling(self, admin_repository):
"""Тест обработки ошибок при получении информации об администраторе"""
# Мокаем ошибку при выполнении запроса
admin_repository._execute_query_with_result.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await admin_repository.get_admin(12345)
@pytest.mark.asyncio
async def test_create_tables_error_handling(self, admin_repository):
"""Тест обработки ошибок при создании таблиц"""
# Мокаем ошибку при выполнении первого запроса
admin_repository._execute_query.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await admin_repository.create_tables()
@pytest.mark.asyncio
async def test_admin_model_compatibility(self, admin_repository):
"""Тест совместимости с моделью Admin"""
user_id = 12345
role = "moderator"
# Создаем администратора с помощью модели
admin = Admin(user_id=user_id, role=role)
# Проверяем, что можем передать его в репозиторий
await admin_repository.add_admin(admin)
# Проверяем, что вызов был с правильными параметрами
call_args = admin_repository._execute_query.call_args
assert call_args[0][1] == (user_id, role)
@pytest.mark.asyncio
async def test_multiple_admin_operations(self, admin_repository):
"""Тест множественных операций с администраторами"""
# Добавляем первого администратора
admin1 = Admin(user_id=111, role="admin")
await admin_repository.add_admin(admin1)
# Добавляем второго администратора
admin2 = Admin(user_id=222, role="moderator")
await admin_repository.add_admin(admin2)
# Проверяем, что оба добавлены
assert admin_repository._execute_query.call_count == 2
# Проверяем, что первый администратор существует
admin_repository._execute_query_with_result.return_value = [(1,)]
result1 = await admin_repository.is_admin(111)
assert result1 is True
# Проверяем, что второй администратор существует
result2 = await admin_repository.is_admin(222)
assert result2 is True
# Удаляем первого администратора
await admin_repository.remove_admin(111)
# Проверяем, что он больше не существует
admin_repository._execute_query_with_result.return_value = []
result3 = await admin_repository.is_admin(111)
assert result3 is False

104
tests/test_async_db.py Normal file
View File

@@ -0,0 +1,104 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch
from database.async_db import AsyncBotDB
class TestAsyncBotDB:
"""Тесты для AsyncBotDB"""
@pytest.fixture
def mock_factory(self):
"""Мок для RepositoryFactory"""
mock_factory = Mock()
mock_factory.audio = Mock()
mock_factory.audio.delete_audio_moderate_record = AsyncMock()
mock_factory.users = Mock()
mock_factory.users.logger = Mock()
return mock_factory
@pytest.fixture
def async_bot_db(self, mock_factory):
"""Экземпляр AsyncBotDB для тестов"""
with patch('database.async_db.RepositoryFactory') as mock_factory_class:
mock_factory_class.return_value = mock_factory
db = AsyncBotDB("test.db")
return db
@pytest.mark.asyncio
async def test_delete_audio_moderate_record(self, async_bot_db, mock_factory):
"""Тест метода delete_audio_moderate_record"""
message_id = 12345
await async_bot_db.delete_audio_moderate_record(message_id)
# Проверяем, что метод вызван в репозитории
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_with_different_message_id(self, async_bot_db, mock_factory):
"""Тест метода delete_audio_moderate_record с разными message_id"""
test_cases = [123, 456, 789, 99999]
for message_id in test_cases:
await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_with(message_id)
# Проверяем, что метод вызван для каждого message_id
assert mock_factory.audio.delete_audio_moderate_record.call_count == len(test_cases)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_exception_handling(self, async_bot_db, mock_factory):
"""Тест обработки исключений в delete_audio_moderate_record"""
message_id = 12345
mock_factory.audio.delete_audio_moderate_record.side_effect = Exception("Database error")
# Метод должен пробросить исключение
with pytest.raises(Exception, match="Database error"):
await async_bot_db.delete_audio_moderate_record(message_id)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_integration_with_other_methods(self, async_bot_db, mock_factory):
"""Тест интеграции delete_audio_moderate_record с другими методами"""
message_id = 12345
user_id = 67890
# Мокаем другие методы
mock_factory.audio.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=user_id)
mock_factory.audio.set_user_id_and_message_id_for_voice_bot = AsyncMock(return_value=True)
# Тестируем последовательность операций
await async_bot_db.get_user_id_by_message_id_for_voice_bot(message_id)
await async_bot_db.set_user_id_and_message_id_for_voice_bot(message_id, user_id)
await async_bot_db.delete_audio_moderate_record(message_id)
# Проверяем, что все методы вызваны
mock_factory.audio.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(message_id)
mock_factory.audio.set_user_id_and_message_id_for_voice_bot.assert_called_once_with(message_id, user_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_zero_message_id(self, async_bot_db, mock_factory):
"""Тест delete_audio_moderate_record с message_id = 0"""
message_id = 0
await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_negative_message_id(self, async_bot_db, mock_factory):
"""Тест delete_audio_moderate_record с отрицательным message_id"""
message_id = -12345
await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)
@pytest.mark.asyncio
async def test_delete_audio_moderate_record_large_message_id(self, async_bot_db, mock_factory):
"""Тест delete_audio_moderate_record с большим message_id"""
message_id = 999999999
await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)

View File

@@ -0,0 +1,277 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock, mock_open
from datetime import datetime
import time
from helper_bot.handlers.voice.services import AudioFileService
from helper_bot.handlers.voice.exceptions import FileOperationError, DatabaseError
@pytest.fixture
def mock_bot_db():
"""Мок для базы данных"""
mock_db = Mock()
mock_db.get_user_audio_records_count = AsyncMock(return_value=0)
mock_db.get_path_for_audio_record = AsyncMock(return_value=None)
mock_db.add_audio_record_simple = AsyncMock()
return mock_db
@pytest.fixture
def audio_service(mock_bot_db):
"""Экземпляр AudioFileService для тестов"""
return AudioFileService(mock_bot_db)
@pytest.fixture
def sample_datetime():
"""Тестовая дата"""
return datetime(2025, 1, 15, 14, 30, 0)
@pytest.fixture
def mock_bot():
"""Мок для бота"""
bot = Mock()
bot.get_file = AsyncMock()
bot.download_file = AsyncMock()
return bot
@pytest.fixture
def mock_message():
"""Мок для сообщения"""
message = Mock()
message.voice = Mock()
message.voice.file_id = "test_file_id"
return message
@pytest.fixture
def mock_file_info():
"""Мок для информации о файле"""
file_info = Mock()
file_info.file_path = "voice/test_file_id.ogg"
return file_info
class TestGenerateFileName:
"""Тесты для метода generate_file_name"""
@pytest.mark.asyncio
async def test_generate_file_name_first_record(self, audio_service, mock_bot_db):
"""Тест генерации имени файла для первой записи пользователя"""
mock_bot_db.get_user_audio_records_count.return_value = 0
result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_1"
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
@pytest.mark.asyncio
async def test_generate_file_name_existing_records(self, audio_service, mock_bot_db):
"""Тест генерации имени файла для существующих записей"""
mock_bot_db.get_user_audio_records_count.return_value = 3
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_3"
result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_4"
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345)
@pytest.mark.asyncio
async def test_generate_file_name_no_last_record(self, audio_service, mock_bot_db):
"""Тест генерации имени файла когда нет последней записи"""
mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = None
result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_3"
@pytest.mark.asyncio
async def test_generate_file_name_invalid_last_record_format(self, audio_service, mock_bot_db):
"""Тест генерации имени файла с некорректным форматом последней записи"""
mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = "invalid_format"
result = await audio_service.generate_file_name(12345)
assert result == "message_from_12345_number_3"
@pytest.mark.asyncio
async def test_generate_file_name_exception_handling(self, audio_service, mock_bot_db):
"""Тест обработки исключений при генерации имени файла"""
mock_bot_db.get_user_audio_records_count.side_effect = Exception("Database error")
with pytest.raises(FileOperationError) as exc_info:
await audio_service.generate_file_name(12345)
assert "Не удалось сгенерировать имя файла" in str(exc_info.value)
class TestSaveAudioFile:
"""Тесты для метода save_audio_file"""
@pytest.mark.asyncio
async def test_save_audio_file_success(self, audio_service, mock_bot_db, sample_datetime):
"""Тест успешного сохранения аудио файла"""
file_name = "test_audio"
user_id = 12345
file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True):
await audio_service.save_audio_file(file_name, user_id, sample_datetime, file_id)
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, sample_datetime)
@pytest.mark.asyncio
async def test_save_audio_file_with_string_date(self, audio_service, mock_bot_db):
"""Тест сохранения аудио файла со строковой датой"""
file_name = "test_audio"
user_id = 12345
date_string = "2025-01-15 14:30:00"
file_id = "test_file_id"
# Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True):
await audio_service.save_audio_file(file_name, user_id, date_string, file_id)
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, date_string)
@pytest.mark.asyncio
async def test_save_audio_file_exception_handling(self, audio_service, mock_bot_db, sample_datetime):
"""Тест обработки исключений при сохранении аудио файла"""
mock_bot_db.add_audio_record_simple.side_effect = Exception("Database error")
# Мокаем verify_file_exists чтобы он возвращал True
with patch.object(audio_service, 'verify_file_exists', return_value=True):
with pytest.raises(DatabaseError) as exc_info:
await audio_service.save_audio_file("test", 12345, sample_datetime, "file_id")
assert "Не удалось сохранить аудио файл в БД" in str(exc_info.value)
class TestDownloadAndSaveAudio:
"""Тесты для метода download_and_save_audio"""
@pytest.mark.asyncio
async def test_download_and_save_audio_success(self, audio_service, mock_bot, mock_message, mock_file_info):
"""Тест успешного скачивания и сохранения аудио"""
mock_bot.get_file.return_value = mock_file_info
# Мокаем скачанный файл
mock_downloaded_file = Mock()
mock_downloaded_file.tell.return_value = 0
mock_downloaded_file.seek = Mock()
mock_downloaded_file.read.return_value = b"audio_data"
# Настраиваем поведение tell() для получения размера файла
def mock_tell():
return 0 if mock_downloaded_file.seek.call_count == 0 else 1024
mock_downloaded_file.tell = Mock(side_effect=mock_tell)
mock_bot.download_file.return_value = mock_downloaded_file
with patch('builtins.open', mock_open()) as mock_file:
with patch('os.makedirs'):
with patch('os.path.exists', return_value=True):
with patch('os.path.getsize', return_value=1024):
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio")
mock_bot.get_file.assert_called_once_with(file_id="test_file_id")
mock_bot.download_file.assert_called_once_with(file_path="voice/test_file_id.ogg")
mock_file.assert_called_once()
@pytest.mark.asyncio
async def test_download_and_save_audio_no_message(self, audio_service, mock_bot):
"""Тест скачивания когда сообщение отсутствует"""
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(mock_bot, None, "test_audio")
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@pytest.mark.asyncio
async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot):
"""Тест скачивания когда у сообщения нет voice атрибута"""
message = Mock()
message.voice = None
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(mock_bot, message, "test_audio")
assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value)
@pytest.mark.asyncio
async def test_download_and_save_audio_download_failed(self, audio_service, mock_bot, mock_message, mock_file_info):
"""Тест скачивания когда загрузка не удалась"""
mock_bot.get_file.return_value = mock_file_info
mock_bot.download_file.return_value = None
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio")
assert "Не удалось скачать файл" in str(exc_info.value)
@pytest.mark.asyncio
async def test_download_and_save_audio_exception_handling(self, audio_service, mock_bot, mock_message):
"""Тест обработки исключений при скачивании"""
mock_bot.get_file.side_effect = Exception("Network error")
with pytest.raises(FileOperationError) as exc_info:
await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio")
assert "Не удалось скачать и сохранить аудио" in str(exc_info.value)
class TestAudioFileServiceIntegration:
"""Интеграционные тесты для AudioFileService"""
@pytest.mark.asyncio
async def test_full_audio_processing_workflow(self, mock_bot_db):
"""Тест полного рабочего процесса обработки аудио"""
service = AudioFileService(mock_bot_db)
# Настраиваем моки
mock_bot_db.get_user_audio_records_count.return_value = 1
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1"
mock_bot_db.add_audio_record_simple = AsyncMock()
# Тестируем генерацию имени файла
file_name = await service.generate_file_name(12345)
assert file_name == "message_from_12345_number_2"
# Тестируем сохранение в БД
test_date = datetime.now()
with patch.object(service, 'verify_file_exists', return_value=True):
await service.save_audio_file(file_name, 12345, test_date, "test_file_id")
# Проверяем вызовы
mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345)
mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345)
mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, 12345, test_date)
@pytest.mark.asyncio
async def test_file_name_generation_sequence(self, mock_bot_db):
"""Тест последовательности генерации имен файлов"""
service = AudioFileService(mock_bot_db)
# Первая запись
mock_bot_db.get_user_audio_records_count.return_value = 0
file_name_1 = await service.generate_file_name(12345)
assert file_name_1 == "message_from_12345_number_1"
# Вторая запись
mock_bot_db.get_user_audio_records_count.return_value = 1
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1"
file_name_2 = await service.generate_file_name(12345)
assert file_name_2 == "message_from_12345_number_2"
# Третья запись
mock_bot_db.get_user_audio_records_count.return_value = 2
mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_2"
file_name_3 = await service.generate_file_name(12345)
assert file_name_3 == "message_from_12345_number_3"
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -0,0 +1,408 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import time
from database.repositories.audio_repository import AudioRepository
from database.models import AudioMessage, AudioListenRecord, AudioModerate
class TestAudioRepository:
"""Тесты для AudioRepository"""
@pytest.fixture
def mock_db_connection(self):
"""Мок для DatabaseConnection"""
mock_connection = Mock()
mock_connection._execute_query = AsyncMock()
mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock()
return mock_connection
@pytest.fixture
def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(AudioRepository, '__init__', return_value=None):
repo = AudioRepository()
repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result
repo.logger = mock_db_connection.logger
return repo
@pytest.fixture
def sample_audio_message(self):
"""Тестовое аудио сообщение"""
return AudioMessage(
file_name="test_audio_123.ogg",
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
)
@pytest.fixture
def sample_datetime(self):
"""Тестовая дата"""
return datetime(2025, 1, 15, 14, 30, 0)
@pytest.fixture
def sample_timestamp(self):
"""Тестовый UNIX timestamp"""
return int(time.mktime(datetime(2025, 1, 15, 14, 30, 0).timetuple()))
@pytest.mark.asyncio
async def test_enable_foreign_keys(self, audio_repository):
"""Тест включения внешних ключей"""
await audio_repository.enable_foreign_keys()
audio_repository._execute_query.assert_called_once_with("PRAGMA foreign_keys = ON;")
@pytest.mark.asyncio
async def test_create_tables(self, audio_repository):
"""Тест создания таблиц"""
await audio_repository.create_tables()
# Проверяем, что все три таблицы созданы
assert audio_repository._execute_query.call_count == 3
# Проверяем вызовы для каждой таблицы
calls = audio_repository._execute_query.call_args_list
assert any("audio_message_reference" in str(call) for call in calls)
assert any("user_audio_listens" in str(call) for call in calls)
assert any("audio_moderate" in str(call) for call in calls)
@pytest.mark.asyncio
async def test_add_audio_record_with_string_date(self, audio_repository, sample_audio_message):
"""Тест добавления аудио записи со строковой датой"""
await audio_repository.add_audio_record(sample_audio_message)
# Проверяем, что метод вызван с правильными параметрами
audio_repository._execute_query.assert_called_once()
call_args = audio_repository._execute_query.call_args
assert call_args[0][0] == """
INSERT INTO audio_message_reference (file_name, author_id, date_added)
VALUES (?, ?, ?)
"""
# Проверяем, что date_added преобразован в timestamp
assert call_args[0][1][0] == "test_audio_123.ogg"
assert call_args[0][1][1] == 12345
assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio
async def test_add_audio_record_with_datetime_date(self, audio_repository):
"""Тест добавления аудио записи с datetime датой"""
audio_msg = AudioMessage(
file_name="test_audio_456.ogg",
author_id=67890,
date_added=datetime(2025, 1, 20, 10, 15, 0),
file_id="test_file_id_2",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем, что date_added преобразован в timestamp
call_args = audio_repository._execute_query.call_args
assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio
async def test_add_audio_record_with_timestamp_date(self, audio_repository):
"""Тест добавления аудио записи с timestamp датой"""
timestamp = int(time.time())
audio_msg = AudioMessage(
file_name="test_audio_789.ogg",
author_id=11111,
date_added=timestamp,
file_id="test_file_id_3",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем, что date_added остался timestamp
call_args = audio_repository._execute_query.call_args
assert call_args[0][1][2] == timestamp
@pytest.mark.asyncio
async def test_add_audio_record_simple_with_string_date(self, audio_repository):
"""Тест упрощенного добавления аудио записи со строковой датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00")
# Проверяем, что метод вызван
audio_repository._execute_query.assert_called_once()
call_args = audio_repository._execute_query.call_args
assert call_args[0][1][0] == "test_audio.ogg" # file_name
assert call_args[0][1][1] == 12345 # user_id
assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio
async def test_add_audio_record_simple_with_datetime_date(self, audio_repository, sample_datetime):
"""Тест упрощенного добавления аудио записи с datetime датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, sample_datetime)
# Проверяем, что date_added преобразован в timestamp
call_args = audio_repository._execute_query.call_args
assert isinstance(call_args[0][1][2], int) # timestamp
@pytest.mark.asyncio
async def test_get_last_date_audio(self, audio_repository):
"""Тест получения даты последнего аудио"""
expected_timestamp = 1642248600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(expected_timestamp,)]
result = await audio_repository.get_last_date_audio()
assert result == expected_timestamp
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT date_added FROM audio_message_reference ORDER BY date_added DESC LIMIT 1"
)
@pytest.mark.asyncio
async def test_get_last_date_audio_no_records(self, audio_repository):
"""Тест получения даты последнего аудио когда записей нет"""
audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_last_date_audio()
assert result is None
@pytest.mark.asyncio
async def test_get_user_audio_records_count(self, audio_repository):
"""Тест получения количества аудио записей пользователя"""
audio_repository._execute_query_with_result.return_value = [(5,)]
result = await audio_repository.get_user_audio_records_count(12345)
assert result == 5
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT COUNT(*) FROM audio_message_reference WHERE author_id = ?", (12345,)
)
@pytest.mark.asyncio
async def test_get_path_for_audio_record(self, audio_repository):
"""Тест получения пути к аудио записи пользователя"""
audio_repository._execute_query_with_result.return_value = [("test_audio.ogg",)]
result = await audio_repository.get_path_for_audio_record(12345)
assert result == "test_audio.ogg"
audio_repository._execute_query_with_result.assert_called_once_with(
"""
SELECT file_name FROM audio_message_reference
WHERE author_id = ? ORDER BY date_added DESC LIMIT 1
""", (12345,)
)
@pytest.mark.asyncio
async def test_get_path_for_audio_record_no_records(self, audio_repository):
"""Тест получения пути к аудио записи когда записей нет"""
audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_path_for_audio_record(12345)
assert result is None
@pytest.mark.asyncio
async def test_check_listen_audio(self, audio_repository):
"""Тест проверки непрослушанных аудио"""
# Мокаем результаты запросов
audio_repository._execute_query_with_result.side_effect = [
[("audio1.ogg",), ("audio2.ogg",)], # прослушанные
[("audio1.ogg",), ("audio2.ogg",), ("audio3.ogg",)] # все аудио
]
result = await audio_repository.check_listen_audio(12345)
# Должно вернуться только непрослушанные (audio3.ogg)
assert result == ["audio3.ogg"]
assert audio_repository._execute_query_with_result.call_count == 2
@pytest.mark.asyncio
async def test_mark_listened_audio(self, audio_repository):
"""Тест отметки аудио как прослушанного"""
await audio_repository.mark_listened_audio("test_audio.ogg", 12345)
audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO user_audio_listens (file_name, user_id) VALUES (?, ?)",
("test_audio.ogg", 12345)
)
@pytest.mark.asyncio
async def test_get_user_id_by_file_name(self, audio_repository):
"""Тест получения user_id по имени файла"""
audio_repository._execute_query_with_result.return_value = [(12345,)]
result = await audio_repository.get_user_id_by_file_name("test_audio.ogg")
assert result == 12345
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT author_id FROM audio_message_reference WHERE file_name = ?", ("test_audio.ogg",)
)
@pytest.mark.asyncio
async def test_get_user_id_by_file_name_not_found(self, audio_repository):
"""Тест получения user_id по имени файла когда файл не найден"""
audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_user_id_by_file_name("nonexistent.ogg")
assert result is None
@pytest.mark.asyncio
async def test_get_date_by_file_name(self, audio_repository):
"""Тест получения даты по имени файла"""
timestamp = 1642404600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата
assert result == "17.01.2022 10:30"
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT date_added FROM audio_message_reference WHERE file_name = ?", ("test_audio.ogg",)
)
@pytest.mark.asyncio
async def test_get_date_by_file_name_not_found(self, audio_repository):
"""Тест получения даты по имени файла когда файл не найден"""
audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_date_by_file_name("nonexistent.ogg")
assert result is None
@pytest.mark.asyncio
async def test_refresh_listen_audio(self, audio_repository):
"""Тест очистки записей прослушивания пользователя"""
await audio_repository.refresh_listen_audio(12345)
audio_repository._execute_query.assert_called_once_with(
"DELETE FROM user_audio_listens WHERE user_id = ?", (12345,)
)
@pytest.mark.asyncio
async def test_delete_listen_count_for_user(self, audio_repository):
"""Тест удаления данных о прослушанных аудио пользователя"""
await audio_repository.delete_listen_count_for_user(12345)
audio_repository._execute_query.assert_called_once_with(
"DELETE FROM user_audio_listens WHERE user_id = ?", (12345,)
)
@pytest.mark.asyncio
async def test_set_user_id_and_message_id_for_voice_bot_success(self, audio_repository):
"""Тест успешной установки связи для voice bot"""
result = await audio_repository.set_user_id_and_message_id_for_voice_bot(123, 456)
assert result is True
audio_repository._execute_query.assert_called_once_with(
"INSERT OR IGNORE INTO audio_moderate (user_id, message_id) VALUES (?, ?)",
(456, 123)
)
@pytest.mark.asyncio
async def test_set_user_id_and_message_id_for_voice_bot_exception(self, audio_repository):
"""Тест установки связи для voice bot при ошибке"""
audio_repository._execute_query.side_effect = Exception("Database error")
result = await audio_repository.set_user_id_and_message_id_for_voice_bot(123, 456)
assert result is False
@pytest.mark.asyncio
async def test_get_user_id_by_message_id_for_voice_bot(self, audio_repository):
"""Тест получения user_id по message_id для voice bot"""
audio_repository._execute_query_with_result.return_value = [(456,)]
result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123)
assert result == 456
audio_repository._execute_query_with_result.assert_called_once_with(
"SELECT user_id FROM audio_moderate WHERE message_id = ?", (123,)
)
@pytest.mark.asyncio
async def test_get_user_id_by_message_id_for_voice_bot_not_found(self, audio_repository):
"""Тест получения user_id по message_id когда связь не найдена"""
audio_repository._execute_query_with_result.return_value = []
result = await audio_repository.get_user_id_by_message_id_for_voice_bot(123)
assert result is None
@pytest.mark.asyncio
async def test_delete_audio_moderate_record(self, audio_repository):
"""Тест удаления записи из таблицы audio_moderate"""
message_id = 12345
await audio_repository.delete_audio_moderate_record(message_id)
audio_repository._execute_query.assert_called_once_with(
"DELETE FROM audio_moderate WHERE message_id = ?", (message_id,)
)
audio_repository.logger.info.assert_called_once_with(
f"Удалена запись из audio_moderate для message_id {message_id}"
)
@pytest.mark.asyncio
async def test_add_audio_record_logging(self, audio_repository, sample_audio_message):
"""Тест логирования при добавлении аудио записи"""
await audio_repository.add_audio_record(sample_audio_message)
# Проверяем, что лог записан
audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0]
assert "Аудио добавлено" in log_message
assert "test_audio_123.ogg" in log_message
assert "12345" in log_message
@pytest.mark.asyncio
async def test_add_audio_record_simple_logging(self, audio_repository):
"""Тест логирования при упрощенном добавлении аудио записи"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00")
# Проверяем, что лог записан
audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0]
assert "Аудио добавлено" in log_message
assert "test_audio.ogg" in log_message
assert "12345" in log_message
@pytest.mark.asyncio
async def test_get_date_by_file_name_logging(self, audio_repository):
"""Тест логирования при получении даты по имени файла"""
timestamp = 1642404600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(timestamp,)]
await audio_repository.get_date_by_file_name("test_audio.ogg")
# Проверяем, что лог записан
audio_repository.logger.info.assert_called_once()
log_message = audio_repository.logger.info.call_args[0][0]
assert "Получена дата" in log_message
assert "17.01.2022 10:30" in log_message
assert "test_audio.ogg" in log_message
class TestAudioRepositoryIntegration:
"""Интеграционные тесты для AudioRepository"""
@pytest.fixture
def real_audio_repository(self):
"""Реальный экземпляр AudioRepository для интеграционных тестов"""
# Здесь можно создать реальное подключение к тестовой БД
# Но для простоты используем мок
return Mock()
@pytest.mark.asyncio
async def test_full_audio_workflow(self, real_audio_repository):
"""Тест полного рабочего процесса с аудио"""
# Этот тест можно расширить для реальной БД
assert True # Placeholder для будущих интеграционных тестов
@pytest.mark.asyncio
async def test_foreign_keys_enabled(self, real_audio_repository):
"""Тест что внешние ключи включены"""
# Этот тест можно расширить для реальной БД
assert True # Placeholder для будущих интеграционных тестов

View File

@@ -0,0 +1,397 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import time
from database.repositories.audio_repository import AudioRepository
class TestAudioRepositoryNewSchema:
"""Тесты для AudioRepository с новой схемой БД"""
@pytest.fixture
def mock_db_connection(self):
"""Мок для DatabaseConnection"""
mock_connection = Mock()
mock_connection._execute_query = AsyncMock()
mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock()
return mock_connection
@pytest.fixture
def audio_repository(self, mock_db_connection):
"""Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None):
repo = AudioRepository()
repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result
repo.logger = mock_db_connection.logger
return repo
@pytest.mark.asyncio
async def test_create_tables_new_schema(self, audio_repository):
"""Тест создания таблиц с новой схемой БД"""
await audio_repository.create_tables()
# Проверяем, что все три таблицы созданы
assert audio_repository._execute_query.call_count == 3
# Получаем все вызовы
calls = audio_repository._execute_query.call_args_list
# Проверяем таблицу audio_message_reference
audio_table_call = next(call for call in calls if "audio_message_reference" in str(call))
assert "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in str(audio_table_call)
assert "file_name TEXT NOT NULL UNIQUE" in str(audio_table_call)
assert "author_id INTEGER NOT NULL" in str(audio_table_call)
assert "date_added INTEGER NOT NULL" in str(audio_table_call)
assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(audio_table_call)
# Проверяем таблицу user_audio_listens
listens_table_call = next(call for call in calls if "user_audio_listens" in str(call))
assert "file_name TEXT NOT NULL" in str(listens_table_call)
assert "user_id INTEGER NOT NULL" in str(listens_table_call)
assert "PRIMARY KEY (file_name, user_id)" in str(listens_table_call)
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(listens_table_call)
# Проверяем таблицу audio_moderate
moderate_table_call = next(call for call in calls if "audio_moderate" in str(call))
assert "user_id INTEGER NOT NULL" in str(moderate_table_call)
assert "message_id INTEGER" in str(moderate_table_call)
assert "PRIMARY KEY (user_id, message_id)" in str(moderate_table_call)
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in str(moderate_table_call)
@pytest.mark.asyncio
async def test_add_audio_record_string_date_conversion(self, audio_repository):
"""Тест преобразования строковой даты в UNIX timestamp"""
from database.models import AudioMessage
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем, что метод вызван
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
# Проверяем параметры
assert params[0] == "test_audio.ogg"
assert params[1] == 12345
assert isinstance(params[2], int) # timestamp
# Проверяем, что timestamp соответствует дате
expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp())
assert params[2] == expected_timestamp
@pytest.mark.asyncio
async def test_add_audio_record_datetime_conversion(self, audio_repository):
"""Тест преобразования datetime в UNIX timestamp"""
from database.models import AudioMessage
test_datetime = datetime(2025, 1, 20, 10, 15, 30)
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added=test_datetime,
file_id="test_file_id",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем параметры
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
expected_timestamp = int(test_datetime.timestamp())
assert params[2] == expected_timestamp
@pytest.mark.asyncio
async def test_add_audio_record_timestamp_no_conversion(self, audio_repository):
"""Тест что timestamp остается timestamp без преобразования"""
from database.models import AudioMessage
test_timestamp = int(time.time())
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added=test_timestamp,
file_id="test_file_id",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем параметры
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
assert params[2] == test_timestamp
@pytest.mark.asyncio
async def test_add_audio_record_simple_string_date(self, audio_repository):
"""Тест упрощенного добавления со строковой датой"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00")
# Проверяем параметры
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
assert params[0] == "test_audio.ogg"
assert params[1] == 12345
assert isinstance(params[2], int) # timestamp
# Проверяем timestamp
expected_timestamp = int(datetime(2025, 1, 15, 14, 30, 0).timestamp())
assert params[2] == expected_timestamp
@pytest.mark.asyncio
async def test_add_audio_record_simple_datetime(self, audio_repository):
"""Тест упрощенного добавления с datetime"""
test_datetime = datetime(2025, 1, 25, 16, 45, 0)
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, test_datetime)
# Проверяем параметры
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
expected_timestamp = int(test_datetime.timestamp())
assert params[2] == expected_timestamp
@pytest.mark.asyncio
async def test_get_date_by_file_name_timestamp_conversion(self, audio_repository):
"""Тест преобразования UNIX timestamp в читаемую дату"""
test_timestamp = 1642248600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
# Должна вернуться читаемая дата в формате dd.mm.yyyy HH:MM
assert result == "15.01.2022 15:10"
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_get_date_by_file_name_different_timestamp(self, audio_repository):
"""Тест преобразования другого timestamp в читаемую дату"""
test_timestamp = 1705312800 # 2024-01-16 12:00:00
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "15.01.2024 13:00"
@pytest.mark.asyncio
async def test_get_date_by_file_name_midnight(self, audio_repository):
"""Тест преобразования timestamp для полуночи"""
test_timestamp = 1705190400 # 2024-01-15 00:00:00
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "14.01.2024 03:00"
@pytest.mark.asyncio
async def test_get_date_by_file_name_year_end(self, audio_repository):
"""Тест преобразования timestamp для конца года"""
test_timestamp = 1704067200 # 2023-12-31 23:59:59
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.2024 03:00"
@pytest.mark.asyncio
async def test_foreign_keys_enabled_called(self, audio_repository):
"""Тест что метод enable_foreign_keys вызывается"""
await audio_repository.enable_foreign_keys()
audio_repository._execute_query.assert_called_once_with("PRAGMA foreign_keys = ON;")
audio_repository.logger.info.assert_not_called() # Этот метод не логирует
@pytest.mark.asyncio
async def test_create_tables_logging(self, audio_repository):
"""Тест логирования при создании таблиц"""
await audio_repository.create_tables()
# Проверяем, что лог записан
audio_repository.logger.info.assert_called_once_with("Таблицы для аудио созданы")
@pytest.mark.asyncio
async def test_add_audio_record_logging_format(self, audio_repository):
"""Тест формата лога при добавлении аудио записи"""
from database.models import AudioMessage
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added="2025-01-15 14:30:00",
file_id="test_file_id",
listen_count=0
)
await audio_repository.add_audio_record(audio_msg)
# Проверяем формат лога
log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0]
assert "Аудио добавлено:" in log_message
assert "file_name=test_audio.ogg" in log_message
assert "author_id=12345" in log_message
@pytest.mark.asyncio
async def test_add_audio_record_simple_logging_format(self, audio_repository):
"""Тест формата лога при упрощенном добавлении"""
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "2025-01-15 14:30:00")
# Проверяем формат лога
log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0]
assert "Аудио добавлено:" in log_message
assert "file_name=test_audio.ogg" in log_message
assert "user_id=12345" in log_message
@pytest.mark.asyncio
async def test_get_date_by_file_name_logging_format(self, audio_repository):
"""Тест формата лога при получении даты"""
test_timestamp = 1642248600 # 2022-01-17 10:30:00
audio_repository._execute_query_with_result.return_value = [(test_timestamp,)]
await audio_repository.get_date_by_file_name("test_audio.ogg")
# Проверяем формат лога
log_call = audio_repository.logger.info.call_args
log_message = log_call[0][0]
assert "Получена дата" in log_message
assert "15.01.2022 15:10" in log_message
assert "test_audio.ogg" in log_message
class TestAudioRepositoryEdgeCases:
"""Тесты граничных случаев для AudioRepository"""
@pytest.fixture
def audio_repository(self):
"""Экземпляр AudioRepository для тестов"""
with patch.object(AudioRepository, '__init__', return_value=None):
repo = AudioRepository()
repo._execute_query = AsyncMock()
repo._execute_query_with_result = AsyncMock()
repo.logger = Mock()
return repo
@pytest.mark.asyncio
async def test_add_audio_record_empty_string_date(self, audio_repository):
"""Тест добавления с пустой строковой датой"""
from database.models import AudioMessage
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added="",
file_id="test_file_id",
listen_count=0
)
# Должно вызвать ValueError при парсинге пустой строки
with pytest.raises(ValueError):
await audio_repository.add_audio_record(audio_msg)
@pytest.mark.asyncio
async def test_add_audio_record_invalid_string_date(self, audio_repository):
"""Тест добавления с некорректной строковой датой"""
from database.models import AudioMessage
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added="invalid_date",
file_id="test_file_id",
listen_count=0
)
# Должно вызвать ValueError при парсинге некорректной даты
with pytest.raises(ValueError):
await audio_repository.add_audio_record(audio_msg)
@pytest.mark.asyncio
async def test_add_audio_record_none_date(self, audio_repository):
"""Тест добавления с None датой"""
from database.models import AudioMessage
audio_msg = AudioMessage(
file_name="test_audio.ogg",
author_id=12345,
date_added=None,
file_id="test_file_id",
listen_count=0
)
# Метод обрабатывает None как timestamp без преобразования
await audio_repository.add_audio_record(audio_msg)
# Проверяем, что метод был вызван с None
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
assert params[2] is None
@pytest.mark.asyncio
async def test_add_audio_record_simple_empty_string_date(self, audio_repository):
"""Тест упрощенного добавления с пустой строковой датой"""
# Должно вызвать ValueError при парсинге пустой строки
with pytest.raises(ValueError):
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "")
@pytest.mark.asyncio
async def test_add_audio_record_simple_invalid_string_date(self, audio_repository):
"""Тест упрощенного добавления с некорректной строковой датой"""
# Должно вызвать ValueError при парсинге некорректной даты
with pytest.raises(ValueError):
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, "invalid_date")
@pytest.mark.asyncio
async def test_add_audio_record_simple_none_date(self, audio_repository):
"""Тест упрощенного добавления с None датой"""
# Метод обрабатывает None как timestamp без преобразования
await audio_repository.add_audio_record_simple("test_audio.ogg", 12345, None)
# Проверяем, что метод был вызван с None
call_args = audio_repository._execute_query.call_args
params = call_args[0][1]
assert params[2] is None
@pytest.mark.asyncio
async def test_get_date_by_file_name_zero_timestamp(self, audio_repository):
"""Тест получения даты для timestamp = 0 (1970-01-01)"""
audio_repository._execute_query_with_result.return_value = [(0,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.1970 03:00"
@pytest.mark.asyncio
async def test_get_date_by_file_name_negative_timestamp(self, audio_repository):
"""Тест получения даты для отрицательного timestamp"""
audio_repository._execute_query_with_result.return_value = [(-3600,)] # 1969-12-31 23:00:00
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "01.01.1970 02:00"
@pytest.mark.asyncio
async def test_get_date_by_file_name_future_timestamp(self, audio_repository):
"""Тест получения даты для будущего timestamp"""
future_timestamp = int(datetime(2030, 12, 31, 23, 59, 59).timestamp())
audio_repository._execute_query_with_result.return_value = [(future_timestamp,)]
result = await audio_repository.get_date_by_file_name("test_audio.ogg")
assert result == "31.12.2030 23:59"

View File

@@ -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 INTEGER
)
''')
# Добавляем тестовые данные
today_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
tomorrow_timestamp = int((datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).timestamp())
test_data = [
(123, "test_user1", "Test ban 1", today_timestamp), # Разблокируется сегодня
(456, "test_user2", "Test ban 2", today_timestamp), # Разблокируется сегодня
(789, "test_user3", "Test ban 3", tomorrow_timestamp), # Разблокируется завтра
(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.async_db import AsyncBotDB
import os
mock_factory.database = AsyncBotDB(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()
# Проверяем, что пользователи с сегодняшней датой разблокированы
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
(current_timestamp,))
today_count = cursor.fetchone()[0]
assert today_count == 0
# Проверяем, что пользователи с завтрашней датой остались
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban > ?",
(current_timestamp,))
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()
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("DELETE FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?", (current_timestamp,))
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
# Проверяем, что дата в базе соответствует ожидаемому формату (timestamp)
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]:
timestamp = result[0]
# Проверяем, что это валидный timestamp (целое число)
assert isinstance(timestamp, int)
assert timestamp > 0
# Проверяем, что timestamp можно преобразовать в дату
date_obj = datetime.fromtimestamp(timestamp)
assert isinstance(date_obj, datetime)
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

View File

@@ -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 = AsyncMock(return_value={
123: "test_user1",
456: "test_user2"
})
mock_db.delete_user_blacklist = AsyncMock(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 = AsyncMock(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 = AsyncMock(return_value={
123: "test_user1",
456: "test_user2"
})
# Первый вызов успешен, второй - ошибка
mock_bot_db.delete_user_blacklist = AsyncMock(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 = AsyncMock(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 "ID: 123" 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 = AsyncMock(return_value={123: "test_user"})
mock_bot_db.delete_user_blacklist = AsyncMock(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()

View File

@@ -0,0 +1,423 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import time
from database.repositories.blacklist_repository import BlacklistRepository
from database.models import BlacklistUser
class TestBlacklistRepository:
"""Тесты для BlacklistRepository"""
@pytest.fixture
def mock_db_connection(self):
"""Мок для DatabaseConnection"""
mock_connection = Mock()
mock_connection._execute_query = AsyncMock()
mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock()
return mock_connection
@pytest.fixture
def blacklist_repository(self, mock_db_connection):
"""Экземпляр BlacklistRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(BlacklistRepository, '__init__', return_value=None):
repo = BlacklistRepository()
repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result
repo.logger = mock_db_connection.logger
return repo
@pytest.fixture
def sample_blacklist_user(self):
"""Тестовый пользователь в черном списке"""
return BlacklistUser(
user_id=12345,
message_for_user="Нарушение правил",
date_to_unban=int(time.time()) + 86400, # +1 день
created_at=int(time.time())
)
@pytest.fixture
def sample_blacklist_user_permanent(self):
"""Тестовый пользователь с постоянным баном"""
return BlacklistUser(
user_id=67890,
message_for_user="Постоянный бан",
date_to_unban=None,
created_at=int(time.time())
)
@pytest.mark.asyncio
async def test_create_tables(self, blacklist_repository):
"""Тест создания таблицы черного списка"""
await blacklist_repository.create_tables()
# Проверяем, что метод вызван
blacklist_repository._execute_query.assert_called()
calls = blacklist_repository._execute_query.call_args_list
# Проверяем, что создается таблица с правильной структурой
create_table_call = calls[0]
assert "CREATE TABLE IF NOT EXISTS blacklist" in create_table_call[0][0]
assert "user_id INTEGER NOT NULL PRIMARY KEY" in create_table_call[0][0]
assert "message_for_user TEXT" in create_table_call[0][0]
assert "date_to_unban INTEGER" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0]
assert "FOREIGN KEY (user_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in create_table_call[0][0]
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with("Таблица черного списка создана")
@pytest.mark.asyncio
async def test_add_user(self, blacklist_repository, sample_blacklist_user):
"""Тест добавления пользователя в черный список"""
await blacklist_repository.add_user(sample_blacklist_user)
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query.assert_called_once()
call_args = blacklist_repository._execute_query.call_args
# Проверяем SQL запрос (учитываем форматирование)
sql_query = call_args[0][0].replace('\n', ' ').replace(' ', ' ').replace(' ', ' ').strip()
expected_sql = "INSERT INTO blacklist (user_id, message_for_user, date_to_unban) VALUES (?, ?, ?)"
assert sql_query == expected_sql
# Проверяем параметры
assert call_args[0][1] == (12345, "Нарушение правил", sample_blacklist_user.date_to_unban)
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Пользователь добавлен в черный список: user_id=12345"
)
@pytest.mark.asyncio
async def test_add_user_permanent_ban(self, blacklist_repository, sample_blacklist_user_permanent):
"""Тест добавления пользователя с постоянным баном"""
await blacklist_repository.add_user(sample_blacklist_user_permanent)
call_args = blacklist_repository._execute_query.call_args
assert call_args[0][1] == (67890, "Постоянный бан", None)
blacklist_repository.logger.info.assert_called_once_with(
"Пользователь добавлен в черный список: user_id=67890"
)
@pytest.mark.asyncio
async def test_remove_user_success(self, blacklist_repository):
"""Тест успешного удаления пользователя из черного списка"""
await blacklist_repository.remove_user(12345)
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query.assert_called_once()
call_args = blacklist_repository._execute_query.call_args
assert call_args[0][0] == "DELETE FROM blacklist WHERE user_id = ?"
assert call_args[0][1] == (12345,)
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Пользователь с идентификатором 12345 успешно удален из черного списка."
)
@pytest.mark.asyncio
async def test_remove_user_failure(self, blacklist_repository):
"""Тест неудачного удаления пользователя из черного списка"""
# Симулируем ошибку при удалении
blacklist_repository._execute_query.side_effect = Exception("Database error")
result = await blacklist_repository.remove_user(12345)
# Проверяем, что возвращается False при ошибке
assert result is False
# Проверяем логирование ошибки
blacklist_repository.logger.error.assert_called_once()
error_log = blacklist_repository.logger.error.call_args[0][0]
assert "Ошибка удаления пользователя с идентификатором 12345" in error_log
assert "Database error" in error_log
@pytest.mark.asyncio
async def test_user_exists_true(self, blacklist_repository):
"""Тест проверки существования пользователя (пользователь существует)"""
# Симулируем результат запроса - пользователь найден
blacklist_repository._execute_query_with_result.return_value = [(1,)]
result = await blacklist_repository.user_exists(12345)
# Проверяем, что возвращается True
assert result is True
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT 1 FROM blacklist WHERE user_id = ?"
assert call_args[0][1] == (12345,)
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Существует ли пользователь: user_id=12345 Итог: [(1,)]"
)
@pytest.mark.asyncio
async def test_user_exists_false(self, blacklist_repository):
"""Тест проверки существования пользователя (пользователь не существует)"""
# Симулируем результат запроса - пользователь не найден
blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.user_exists(12345)
# Проверяем, что возвращается False
assert result is False
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Существует ли пользователь: user_id=12345 Итог: []"
)
@pytest.mark.asyncio
async def test_get_user_success(self, blacklist_repository):
"""Тест успешного получения пользователя по ID"""
# Симулируем результат запроса
mock_row = (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()))
blacklist_repository._execute_query_with_result.return_value = [mock_row]
result = await blacklist_repository.get_user(12345)
# Проверяем, что возвращается правильный объект
assert result is not None
assert result.user_id == 12345
assert result.message_for_user == "Нарушение правил"
assert result.date_to_unban == mock_row[2]
assert result.created_at == mock_row[3]
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist WHERE user_id = ?"
assert call_args[0][1] == (12345,)
@pytest.mark.asyncio
async def test_get_user_not_found(self, blacklist_repository):
"""Тест получения пользователя по ID (пользователь не найден)"""
# Симулируем результат запроса - пользователь не найден
blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_user(12345)
# Проверяем, что возвращается None
assert result is None
@pytest.mark.asyncio
async def test_get_all_users_with_limits(self, blacklist_repository):
"""Тест получения пользователей с лимитами"""
# Симулируем результат запроса
mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())),
(67890, "Постоянный бан", None, int(time.time()) - 86400)
]
blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_all_users(offset=0, limit=10)
# Проверяем, что возвращается правильный список
assert len(result) == 2
assert result[0].user_id == 12345
assert result[0].message_for_user == "Нарушение правил"
assert result[1].user_id == 67890
assert result[1].message_for_user == "Постоянный бан"
assert result[1].date_to_unban is None
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?"
assert call_args[0][1] == (0, 10)
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей в черном списке (offset=0, limit=10): 2"
)
@pytest.mark.asyncio
async def test_get_all_users_no_limit(self, blacklist_repository):
"""Тест получения всех пользователей без лимитов"""
# Симулируем результат запроса
mock_rows = [
(12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())),
(67890, "Постоянный бан", None, int(time.time()) - 86400)
]
blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_all_users_no_limit()
# Проверяем, что возвращается правильный список
assert len(result) == 2
# Проверяем, что метод вызван без лимитов
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist"
# Проверяем, что параметры пустые (без лимитов)
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Получен список всех пользователей в черном списке: 2"
)
@pytest.mark.asyncio
async def test_get_users_for_unblock_today(self, blacklist_repository):
"""Тест получения пользователей для разблокировки сегодня"""
current_timestamp = int(time.time())
# Симулируем результат запроса - пользователи с истекшим сроком
mock_rows = [(12345,), (67890,)]
blacklist_repository._execute_query_with_result.return_value = mock_rows
result = await blacklist_repository.get_users_for_unblock_today(current_timestamp)
# Проверяем, что возвращается правильный словарь
assert len(result) == 2
assert 12345 in result
assert 67890 in result
assert result[12345] == 12345
assert result[67890] == 67890
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT user_id FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?"
assert call_args[0][1] == (current_timestamp,)
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей для разблокировки: {12345: 12345, 67890: 67890}"
)
@pytest.mark.asyncio
async def test_get_users_for_unblock_today_empty(self, blacklist_repository):
"""Тест получения пользователей для разблокировки (пустой результат)"""
current_timestamp = int(time.time())
# Симулируем пустой результат запроса
blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_users_for_unblock_today(current_timestamp)
# Проверяем, что возвращается пустой словарь
assert result == {}
# Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with(
"Получен список пользователей для разблокировки: {}"
)
@pytest.mark.asyncio
async def test_get_count(self, blacklist_repository):
"""Тест получения количества пользователей в черном списке"""
# Симулируем результат запроса
blacklist_repository._execute_query_with_result.return_value = [(5,)]
result = await blacklist_repository.get_count()
# Проверяем, что возвращается правильное количество
assert result == 5
# Проверяем, что метод вызван с правильными параметрами
blacklist_repository._execute_query_with_result.assert_called_once()
call_args = blacklist_repository._execute_query_with_result.call_args
assert call_args[0][0] == "SELECT COUNT(*) FROM blacklist"
# Проверяем, что параметры пустые
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров
@pytest.mark.asyncio
async def test_get_count_zero(self, blacklist_repository):
"""Тест получения количества пользователей (0 пользователей)"""
# Симулируем пустой результат запроса
blacklist_repository._execute_query_with_result.return_value = []
result = await blacklist_repository.get_count()
# Проверяем, что возвращается 0
assert result == 0
@pytest.mark.asyncio
async def test_get_count_none_result(self, blacklist_repository):
"""Тест получения количества пользователей (None результат)"""
# Симулируем None результат запроса
blacklist_repository._execute_query_with_result.return_value = None
result = await blacklist_repository.get_count()
# Проверяем, что возвращается 0
assert result == 0
@pytest.mark.asyncio
async def test_error_handling_in_get_user(self, blacklist_repository):
"""Тест обработки ошибок при получении пользователя"""
# Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed")
# Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_user(12345)
assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio
async def test_error_handling_in_get_all_users(self, blacklist_repository):
"""Тест обработки ошибок при получении всех пользователей"""
# Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed")
# Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_all_users()
assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio
async def test_error_handling_in_get_count(self, blacklist_repository):
"""Тест обработки ошибок при получении количества"""
# Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed")
# Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_count()
assert "Database connection failed" in str(exc_info.value)
@pytest.mark.asyncio
async def test_error_handling_in_get_users_for_unblock_today(self, blacklist_repository):
"""Тест обработки ошибок при получении пользователей для разблокировки"""
# Симулируем ошибку базы данных
blacklist_repository._execute_query_with_result.side_effect = Exception("Database connection failed")
# Проверяем, что исключение пробрасывается
with pytest.raises(Exception) as exc_info:
await blacklist_repository.get_users_for_unblock_today(int(time.time()))
assert "Database connection failed" in str(exc_info.value)
# TODO: 20-й тест - test_integration_workflow
# Этот тест должен проверять полный рабочий процесс:
# 1. Добавление пользователя в черный список
# 2. Проверка существования пользователя
# 3. Получение информации о пользователе
# 4. Получение общего количества пользователей
# 5. Удаление пользователя из черного списка
# 6. Проверка, что пользователь больше не существует
#
# Проблема: тест падает из-за сложности мокирования возвращаемых значений
# при создании объектов BlacklistUser из результатов запросов к БД.
# Требует более сложной настройки моков для корректной работы.

View File

@@ -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'])

View File

@@ -0,0 +1,297 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import time
from helper_bot.handlers.callback.callback_handlers import (
save_voice_message,
delete_voice_message
)
from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE
@pytest.fixture
def mock_call():
"""Мок для CallbackQuery"""
call = Mock()
call.message = Mock()
call.message.message_id = 12345
call.message.voice = Mock()
call.message.voice.file_id = "test_file_id_123"
call.bot = Mock()
call.bot.delete_message = AsyncMock()
call.answer = AsyncMock()
return call
@pytest.fixture
def mock_bot_db():
"""Мок для базы данных"""
mock_db = Mock()
mock_db.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=67890)
mock_db.delete_audio_moderate_record = AsyncMock()
return mock_db
@pytest.fixture
def mock_settings():
"""Мок для настроек"""
return {
'Telegram': {
'group_for_posts': 'test_group_id'
}
}
@pytest.fixture
def mock_audio_service():
"""Мок для AudioFileService"""
mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1")
mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock()
return mock_service
class TestSaveVoiceMessage:
"""Тесты для функции save_voice_message"""
@pytest.mark.asyncio
async def test_save_voice_message_success(self, mock_call, mock_bot_db, mock_settings, mock_audio_service):
"""Тест успешного сохранения голосового сообщения"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что все методы вызваны
mock_bot_db.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(12345)
mock_audio_service.generate_file_name.assert_called_once_with(67890)
mock_audio_service.save_audio_file.assert_called_once()
mock_audio_service.download_and_save_audio.assert_called_once_with(
mock_call.bot, mock_call.message, "message_from_67890_number_1"
)
# Проверяем удаление сообщения из чата
mock_call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id',
message_id=12345
)
# Проверяем удаление записи из audio_moderate
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345)
# Проверяем ответ пользователю
mock_call.answer.assert_called_once_with(text='Сохранено!', cache_time=3)
@pytest.mark.asyncio
async def test_save_voice_message_with_correct_parameters(self, mock_call, mock_bot_db, mock_settings, mock_audio_service):
"""Тест сохранения с правильными параметрами"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем параметры save_audio_file
save_call_args = mock_audio_service.save_audio_file.call_args
assert save_call_args[0][0] == "message_from_67890_number_1" # file_name
assert save_call_args[0][1] == 67890 # user_id
assert isinstance(save_call_args[0][2], datetime) # date_added
assert save_call_args[0][3] == "test_file_id_123" # file_id
@pytest.mark.asyncio
async def test_save_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings):
"""Тест обработки исключений при сохранении"""
mock_bot_db.get_user_id_by_message_id_for_voice_bot.side_effect = Exception("Database error")
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
@pytest.mark.asyncio
async def test_save_voice_message_audio_service_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service):
"""Тест обработки исключений в AudioFileService"""
mock_audio_service.save_audio_file.side_effect = Exception("Save error")
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
@pytest.mark.asyncio
async def test_save_voice_message_download_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service):
"""Тест обработки исключений при скачивании файла"""
mock_audio_service.download_and_save_audio.side_effect = Exception("Download error")
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service_class.return_value = mock_audio_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
class TestDeleteVoiceMessage:
"""Тесты для функции delete_voice_message"""
@pytest.mark.asyncio
async def test_delete_voice_message_success(self, mock_call, mock_bot_db, mock_settings):
"""Тест успешного удаления голосового сообщения"""
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем удаление сообщения из чата
mock_call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id',
message_id=12345
)
# Проверяем удаление записи из audio_moderate
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345)
# Проверяем ответ пользователю
mock_call.answer.assert_called_once_with(text='Удалено!', cache_time=3)
@pytest.mark.asyncio
async def test_delete_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings):
"""Тест обработки исключений при удалении"""
mock_call.bot.delete_message.side_effect = Exception("Delete error")
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3)
@pytest.mark.asyncio
async def test_delete_voice_message_database_exception(self, mock_call, mock_bot_db, mock_settings):
"""Тест обработки исключений в базе данных при удалении"""
mock_bot_db.delete_audio_moderate_record.side_effect = Exception("Database error")
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что при ошибке отправляется соответствующий ответ
mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3)
class TestCallbackHandlersIntegration:
"""Интеграционные тесты для callback handlers"""
@pytest.mark.asyncio
async def test_save_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings):
"""Тест полного рабочего процесса сохранения"""
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1")
mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock()
mock_service_class.return_value = mock_service
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем последовательность вызовов
assert mock_bot_db.get_user_id_by_message_id_for_voice_bot.called
assert mock_service.generate_file_name.called
assert mock_service.save_audio_file.called
assert mock_service.download_and_save_audio.called
assert mock_call.bot.delete_message.called
assert mock_bot_db.delete_audio_moderate_record.called
assert mock_call.answer.called
@pytest.mark.asyncio
async def test_delete_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings):
"""Тест полного рабочего процесса удаления"""
await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем последовательность вызовов
assert mock_call.bot.delete_message.called
assert mock_bot_db.delete_audio_moderate_record.called
assert mock_call.answer.called
@pytest.mark.asyncio
async def test_audio_moderate_cleanup_consistency(self, mock_call, mock_bot_db, mock_settings):
"""Тест консистентности очистки audio_moderate"""
# Тестируем, что в обоих случаях (сохранение и удаление)
# вызывается delete_audio_moderate_record
# Создаем отдельные моки для каждого теста
mock_bot_db_save = Mock()
mock_bot_db_save.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=67890)
mock_bot_db_save.delete_audio_moderate_record = AsyncMock()
mock_bot_db_delete = Mock()
mock_bot_db_delete.delete_audio_moderate_record = AsyncMock()
# Тест для сохранения
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class:
mock_service = Mock()
mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1")
mock_service.save_audio_file = AsyncMock()
mock_service.download_and_save_audio = AsyncMock()
mock_service_class.return_value = mock_service
await save_voice_message(mock_call, bot_db=mock_bot_db_save, settings=mock_settings)
save_calls = mock_bot_db_save.delete_audio_moderate_record.call_count
# Тест для удаления
await delete_voice_message(mock_call, bot_db=mock_bot_db_delete, settings=mock_settings)
delete_calls = mock_bot_db_delete.delete_audio_moderate_record.call_count
# Проверяем, что в обоих случаях вызывается очистка
assert save_calls == 1
assert delete_calls == 1
class TestCallbackHandlersEdgeCases:
"""Тесты граничных случаев для callback handlers"""
@pytest.mark.asyncio
async def test_save_voice_message_no_voice_attribute(self, mock_bot_db, mock_settings):
"""Тест сохранения когда у сообщения нет voice атрибута"""
call = Mock()
call.message = Mock()
call.message.message_id = 12345
call.message.voice = None # Нет голосового сообщения
call.bot = Mock()
call.bot.delete_message = AsyncMock()
call.answer = AsyncMock()
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'):
await save_voice_message(call, bot_db=mock_bot_db, settings=mock_settings)
# Должна быть ошибка
call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
@pytest.mark.asyncio
async def test_save_voice_message_user_not_found(self, mock_call, mock_bot_db, mock_settings):
"""Тест сохранения когда пользователь не найден"""
mock_bot_db.get_user_id_by_message_id_for_voice_bot.return_value = None
with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'):
await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings)
# Должна быть ошибка
mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3)
@pytest.mark.asyncio
async def test_delete_voice_message_with_different_message_id(self, mock_bot_db, mock_settings):
"""Тест удаления с другим message_id"""
call = Mock()
call.message = Mock()
call.message.message_id = 99999 # Другой ID
call.bot = Mock()
call.bot.delete_message = AsyncMock()
call.answer = AsyncMock()
await delete_voice_message(call, bot_db=mock_bot_db, settings=mock_settings)
# Проверяем, что используется правильный message_id
call.bot.delete_message.assert_called_once_with(
chat_id='test_group_id',
message_id=99999
)
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999)
if __name__ == '__main__':
pytest.main([__file__])

Some files were not shown because too many files have changed in this diff Show More