diff --git a/.dockerignore b/.dockerignore index da0c729..816ea09 100644 --- a/.dockerignore +++ b/.dockerignore @@ -72,8 +72,6 @@ docker-compose*.yml .dockerignore # Development files -Makefile -start_docker.sh *.sh # Stickers and media @@ -94,4 +92,4 @@ Stick/ # Monitoring configs (will be mounted) prometheus.yml -grafana/ + diff --git a/Makefile b/Makefile deleted file mode 100644 index 3b9526d..0000000 --- a/Makefile +++ /dev/null @@ -1,121 +0,0 @@ -.PHONY: help build up down logs clean restart status deploy migrate backup - -help: ## Показать справку - @echo "🐍 Telegram Bot - Доступные команды (Production Ready):" - @echo "" - @echo "🔧 Основные команды:" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' - @echo "" - @echo "📊 Мониторинг:" - @echo " Prometheus: http://localhost:9090" - @echo " Grafana: http://localhost:3000 (admin/admin)" - @echo " Bot Health: http://localhost:8000/health" - -build: ## Собрать все контейнеры - docker-compose build - -up: ## Запустить все сервисы - docker-compose up -d - -down: ## Остановить все сервисы - docker-compose down - -logs: ## Показать логи всех сервисов - docker-compose logs -f - -logs-bot: ## Показать логи бота - docker-compose logs -f telegram-bot - -logs-prometheus: ## Показать логи Prometheus - docker-compose logs -f prometheus - -logs-grafana: ## Показать логи Grafana - docker-compose logs -f grafana - -restart: ## Перезапустить все сервисы - docker-compose down - docker-compose up -d - -restart-bot: ## Перезапустить только бота - docker-compose restart telegram-bot - -restart-prometheus: ## Перезапустить только Prometheus - docker-compose restart prometheus - -restart-grafana: ## Перезапустить только Grafana - docker-compose restart grafana - -status: ## Показать статус контейнеров - docker-compose ps - -health: ## Проверить здоровье сервисов - @echo "🏥 Checking service health..." - @curl -f http://localhost:8000/health || echo "❌ Bot health check failed" - @curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus health check failed" - @curl -f http://localhost:3000/api/health || echo "❌ Grafana health check failed" - -check-python: ## Проверить версию Python в контейнере - @echo "🐍 Проверяю версию Python в контейнере..." - @docker exec telegram-bot python --version || echo "Контейнер не запущен" - -deploy: ## Полный деплой на продакшен - @echo "🚀 Starting production deployment..." - @chmod +x scripts/deploy.sh - @./scripts/deploy.sh - -migrate: ## Миграция с systemctl + cron на Docker - @echo "🔄 Starting migration from systemctl to Docker..." - @chmod +x scripts/migrate_from_systemctl.sh - @sudo ./scripts/migrate_from_systemctl.sh - -backup: ## Создать backup данных - @echo "💾 Creating backup..." - @mkdir -p backups - @tar -czf "backups/backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env - @echo "✅ Backup created in backups/" - -restore: ## Восстановить из backup (указать файл: make restore FILE=backup.tar.gz) - @echo "🔄 Restoring from backup..." - @if [ -z "$(FILE)" ]; then echo "❌ Please specify backup file: make restore FILE=backup.tar.gz"; exit 1; fi - @tar -xzf "backups/$(FILE)" -C . - @echo "✅ Backup restored" - -update: ## Обновить бота (pull latest code and redeploy) - @echo "📥 Pulling latest changes..." - @git pull origin main - @echo "🔨 Rebuilding and restarting..." - @make restart - -clean: ## Очистить все контейнеры и образы - docker-compose down -v --rmi all - docker system prune -f - -security-scan: ## Сканировать образы на уязвимости - @echo "🔍 Scanning Docker images for vulnerabilities..." - @docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ - -v $(PWD):/workspace \ - --workdir /workspace \ - anchore/grype:latest \ - telegram-helper-bot_telegram-bot:latest || echo "⚠️ Grype not available, skipping scan" - -monitoring: ## Открыть мониторинг в браузере - @echo "📊 Opening monitoring dashboards..." - @open http://localhost:3000 || xdg-open http://localhost:3000 || echo "Please open manually: http://localhost:3000" - -start: build up ## Собрать и запустить все сервисы - @echo "🐍 Telegram Bot запущен!" - @echo "📊 Prometheus: http://localhost:9090" - @echo "📈 Grafana: http://localhost:3000 (admin/admin)" - @echo "🤖 Bot Health: http://localhost:8000/health" - @echo "📝 Логи: make logs" - -stop: down ## Остановить все сервисы - @echo "🛑 Все сервисы остановлены" - -test: ## Запустить все тесты - @echo "🧪 Запускаю все тесты..." - @docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest" - -test-coverage: ## Запустить все тесты с покрытием - @echo "🧪 Запускаю все тесты с покрытием..." - @docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=term-missing" diff --git a/README_TESTING.md b/README_TESTING.md deleted file mode 100644 index 43a32b8..0000000 --- a/README_TESTING.md +++ /dev/null @@ -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" -``` diff --git a/database/schema.sql b/database/schema.sql index 10e1501..5224dc9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,11 +1,8 @@ -- Telegram Helper Bot Database Schema -- Compatible with Docker container deployment --- System table for SQLite auto-increment sequences -CREATE TABLE IF NOT EXISTS sqlite_sequence ( - name TEXT NOT NULL, - seq INTEGER NOT NULL -); +-- 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 listen_audio_users ( diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 3e2876c..1ee9488 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -343,7 +343,7 @@ async def test_metrics_handler( await message.answer( f"✅ Тестовые метрики записаны\n" f"📊 Активных пользователей: {active_users}\n" - f"🔧 Проверьте Grafana дашборд" + f"🔧 Проверьте Prometheus метрики" ) except Exception as e: diff --git a/helper_bot/handlers/admin/constants.py b/helper_bot/handlers/admin/constants.py new file mode 100644 index 0000000..9fddf58 --- /dev/null +++ b/helper_bot/handlers/admin/constants.py @@ -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" +} diff --git a/helper_bot/handlers/callback/constants.py b/helper_bot/handlers/callback/constants.py index a0524fa..02dbd95 100644 --- a/helper_bot/handlers/callback/constants.py +++ b/helper_bot/handlers/callback/constants.py @@ -1,3 +1,5 @@ +from typing import Final, Dict + # Callback data constants CALLBACK_PUBLISH = "publish" CALLBACK_DECLINE = "decline" @@ -27,3 +29,13 @@ MESSAGE_USER_BANNED_SPAM = "Ты заблокирован за спам. Дат # 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" +} diff --git a/helper_bot/handlers/private/constants.py b/helper_bot/handlers/private/constants.py index 5b87a68..8255e92 100644 --- a/helper_bot/handlers/private/constants.py +++ b/helper_bot/handlers/private/constants.py @@ -20,6 +20,16 @@ BUTTON_TEXTS: Final[Dict[str, str]] = { "CONNECT_ADMIN": "📩Связаться с админами" } +# 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" +} + # Error messages ERROR_MESSAGES: Final[Dict[str, str]] = { "UNSUPPORTED_CONTENT": ( diff --git a/helper_bot/middlewares/metrics_middleware.py b/helper_bot/middlewares/metrics_middleware.py index 202f624..e8f113b 100644 --- a/helper_bot/middlewares/metrics_middleware.py +++ b/helper_bot/middlewares/metrics_middleware.py @@ -3,7 +3,7 @@ Metrics middleware for aiogram 3.x. Automatically collects metrics for message processing, command execution, and errors. """ -from typing import Any, Awaitable, Callable, Dict +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 @@ -11,6 +11,18 @@ import time import logging 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 +except ImportError: + # Fallback if constants not available + BUTTON_COMMAND_MAPPING = {} + CALLBACK_COMMAND_MAPPING = {} + ADMIN_BUTTON_COMMAND_MAPPING = {} + ADMIN_COMMANDS = {} + class MetricsMiddleware(BaseMiddleware): """Middleware for automatic metrics collection in aiogram handlers.""" @@ -35,23 +47,11 @@ class MetricsMiddleware(BaseMiddleware): if isinstance(event, Message): self.logger.info(f"📊 Processing Message event") await self._record_message_metrics(event) - if event.text and event.text.startswith('/'): - command_info = { - 'command': event.text.split()[0][1:], # Remove '/' and get command name - 'user_type': "user" if event.from_user else "unknown", - 'handler_type': "message_handler" - } + command_info = self._extract_command_info(event) elif isinstance(event, CallbackQuery): self.logger.info(f"📊 Processing CallbackQuery event") await self._record_callback_metrics(event) - if event.data: - parts = event.data.split(':', 1) - if parts: - command_info = { - 'command': parts[0], - 'user_type': "user" if event.from_user else "unknown", - 'handler_type': "callback_handler" - } + command_info = self._extract_callback_command_info(event) else: self.logger.info(f"📊 Processing unknown event type: {type(event).__name__}") @@ -167,6 +167,62 @@ class MetricsMiddleware(BaseMiddleware): async def _record_callback_metrics(self, callback: CallbackQuery): """Record callback metrics efficiently.""" metrics.record_message("callback_query", "callback", "callback_handler") + + def _extract_command_info(self, message: Message) -> Optional[Dict[str, str]]: + """Extract command information from message (commands or button clicks).""" + 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" + } + else: + return { + 'command': command_name, + 'user_type': "user" if message.from_user else "unknown", + 'handler_type': "message_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" + } + + return None + + def _extract_callback_command_info(self, callback: CallbackQuery) -> Optional[Dict[str, str]]: + """Extract command information from callback query.""" + 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" + } + + return None class DatabaseMetricsMiddleware(BaseMiddleware): diff --git a/run_metrics_only.py b/run_metrics_only.py deleted file mode 100644 index 2911b36..0000000 --- a/run_metrics_only.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -""" -Standalone metrics server for testing. -Run this to start just the metrics system without the bot. -""" - -import asyncio -import signal -import sys -from helper_bot.utils.metrics_exporter import MetricsManager - - -class MetricsServer: - """Standalone metrics server.""" - - def __init__(self, host: str = "0.0.0.0", port: int = 8000): - self.host = host - self.port = port - self.metrics_manager = MetricsManager(host, port) - self.running = False - - async def start(self): - """Start the metrics server.""" - try: - await self.metrics_manager.start() - self.running = True - print(f"🚀 Metrics server started on {self.host}:{self.port}") - print(f"📊 Metrics endpoint: http://{self.host}:{self.port}/metrics") - print(f"🏥 Health check: http://{self.host}:{self.port}/health") - print(f"ℹ️ Info: http://{self.host}:{self.port}/") - print("\nPress Ctrl+C to stop the server") - - # Keep the server running - while self.running: - await asyncio.sleep(1) - - except Exception as e: - print(f"❌ Error starting metrics server: {e}") - raise - - async def stop(self): - """Stop the metrics server.""" - if self.running: - self.running = False - await self.metrics_manager.stop() - print("\n🛑 Metrics server stopped") - - def signal_handler(self, signum, frame): - """Handle shutdown signals.""" - print(f"\n📡 Received signal {signum}, shutting down...") - asyncio.create_task(self.stop()) - - -async def main(): - """Main function.""" - # Parse command line arguments - host = "0.0.0.0" - port = 8000 - - if len(sys.argv) > 1: - host = sys.argv[1] - if len(sys.argv) > 2: - port = int(sys.argv[2]) - - # Create and start server - server = MetricsServer(host, port) - - # Setup signal handlers - signal.signal(signal.SIGINT, server.signal_handler) - signal.signal(signal.SIGTERM, server.signal_handler) - - try: - await server.start() - except KeyboardInterrupt: - print("\n📡 Keyboard interrupt received") - finally: - await server.stop() - - -if __name__ == "__main__": - print("🔧 Starting standalone metrics server...") - print("Usage: python run_metrics_only.py [host] [port]") - print("Default: host=0.0.0.0, port=8000") - print() - - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\n🛑 Server stopped by user") - except Exception as e: - print(f"❌ Server error: {e}") - sys.exit(1) diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index 01c7df5..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Configuration -PROJECT_NAME="telegram-helper-bot" -DOCKER_COMPOSE_FILE="docker-compose.yml" -ENV_FILE=".env" - -echo -e "${GREEN}🚀 Starting deployment of $PROJECT_NAME${NC}" - -# Check if .env file exists -if [ ! -f "$ENV_FILE" ]; then - echo -e "${RED}❌ Error: $ENV_FILE file not found!${NC}" - echo -e "${YELLOW}Please copy env.example to .env and configure your settings${NC}" - exit 1 -fi - -# Load environment variables -source "$ENV_FILE" - -# Validate required environment variables -required_vars=("BOT_TOKEN" "MAIN_PUBLIC" "GROUP_FOR_POSTS" "GROUP_FOR_MESSAGE" "GROUP_FOR_LOGS") -for var in "${required_vars[@]}"; do - if [ -z "${!var}" ]; then - echo -e "${RED}❌ Error: Required environment variable $var is not set${NC}" - exit 1 - fi -done - -echo -e "${GREEN}✅ Environment variables validated${NC}" - -# Create necessary directories -echo -e "${YELLOW}📁 Creating necessary directories...${NC}" -mkdir -p database logs - -# Set proper permissions -echo -e "${YELLOW}🔐 Setting proper permissions...${NC}" -chmod 600 "$ENV_FILE" -chmod 755 database logs - -# Stop existing containers -echo -e "${YELLOW}🛑 Stopping existing containers...${NC}" -docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans || true - -# Remove old images -echo -e "${YELLOW}🧹 Cleaning up old images...${NC}" -docker system prune -f - -# Build and start services -echo -e "${YELLOW}🔨 Building and starting services...${NC}" -docker-compose -f "$DOCKER_COMPOSE_FILE" up -d --build - -# Wait for services to be healthy -echo -e "${YELLOW}⏳ Waiting for services to be healthy...${NC}" -sleep 30 - -# Check service health -echo -e "${YELLOW}🏥 Checking service health...${NC}" -if docker-compose -f "$DOCKER_COMPOSE_FILE" ps | grep -q "unhealthy"; then - echo -e "${RED}❌ Some services are unhealthy!${NC}" - docker-compose -f "$DOCKER_COMPOSE_FILE" logs - exit 1 -fi - -# Show service status -echo -e "${GREEN}📊 Service status:${NC}" -docker-compose -f "$DOCKER_COMPOSE_FILE" ps - -echo -e "${GREEN}✅ Deployment completed successfully!${NC}" -echo -e "${GREEN}📊 Monitoring URLs:${NC}" -echo -e " Prometheus: http://localhost:9090" -echo -e " Grafana: http://localhost:3000" -echo -e " Bot Metrics: http://localhost:8000/metrics" -echo -e " Bot Health: http://localhost:8000/health" -echo -e "" -echo -e "${YELLOW}📝 Useful commands:${NC}" -echo -e " View logs: docker-compose logs -f" -echo -e " Restart: docker-compose restart" -echo -e " Stop: docker-compose down" diff --git a/scripts/migrate_from_systemctl.sh b/scripts/migrate_from_systemctl.sh deleted file mode 100644 index 0f8e629..0000000 --- a/scripts/migrate_from_systemctl.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/bin/bash - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}🔄 Starting migration from systemctl + cron to Docker${NC}" - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}❌ This script must be run as root for systemctl operations${NC}" - exit 1 -fi - -# Configuration -SERVICE_NAME="telegram-helper-bot" -CRON_USER="root" - -echo -e "${YELLOW}📋 Migration steps:${NC}" -echo "1. Stop systemctl service" -echo "2. Disable systemctl service" -echo "3. Remove cron jobs" -echo "4. Backup existing data" -echo "5. Deploy Docker version" - -# Step 1: Stop systemctl service -echo -e "${YELLOW}🛑 Stopping systemctl service...${NC}" -if systemctl is-active --quiet "$SERVICE_NAME"; then - systemctl stop "$SERVICE_NAME" - echo -e "${GREEN}✅ Service stopped${NC}" -else - echo -e "${YELLOW}⚠️ Service was not running${NC}" -fi - -# Step 2: Disable systemctl service -echo -e "${YELLOW}🚫 Disabling systemctl service...${NC}" -if systemctl is-enabled --quiet "$SERVICE_NAME"; then - systemctl disable "$SERVICE_NAME" - echo -e "${GREEN}✅ Service disabled${NC}" -else - echo -e "${YELLOW}⚠️ Service was not enabled${NC}" -fi - -# Step 3: Remove cron jobs -echo -e "${YELLOW}🗑️ Removing cron jobs...${NC}" -if crontab -u "$CRON_USER" -l 2>/dev/null | grep -q "telegram-helper-bot"; then - crontab -u "$CRON_USER" -l 2>/dev/null | grep -v "telegram-helper-bot" | crontab -u "$CRON_USER" - - echo -e "${GREEN}✅ Cron jobs removed${NC}" -else - echo -e "${YELLOW}⚠️ No cron jobs found${NC}" -fi - -# Step 4: Backup existing data -echo -e "${YELLOW}💾 Creating backup...${NC}" -BACKUP_DIR="/backup/telegram-bot-$(date +%Y%m%d-%H%M%S)" -mkdir -p "$BACKUP_DIR" - -# Backup database -if [ -f "database/tg-bot-database.db" ]; then - cp -r database "$BACKUP_DIR/" - echo -e "${GREEN}✅ Database backed up to $BACKUP_DIR/database${NC}" -fi - -# Backup logs -if [ -d "logs" ]; then - cp -r logs "$BACKUP_DIR/" - echo -e "${GREEN}✅ Logs backed up to $BACKUP_DIR/logs${NC}" -fi - -# Backup settings -if [ -f ".env" ]; then - cp .env "$BACKUP_DIR/" - echo -e "${GREEN}✅ Settings backed up to $BACKUP_DIR/.env${NC}" -fi - -# Step 5: Deploy Docker version -echo -e "${YELLOW}🐳 Deploying Docker version...${NC}" - -# Check if Docker is installed -if ! command -v docker &> /dev/null; then - echo -e "${RED}❌ Docker is not installed. Please install Docker first.${NC}" - exit 1 -fi - -if ! command -v docker-compose &> /dev/null; then - echo -e "${RED}❌ Docker Compose is not installed. Please install Docker Compose first.${NC}" - exit 1 -fi - -# Make deploy script executable and run it -chmod +x scripts/deploy.sh -./scripts/deploy.sh - -echo -e "${GREEN}✅ Migration completed successfully!${NC}" -echo -e "${GREEN}📁 Backup location: $BACKUP_DIR${NC}" -echo -e "${YELLOW}📝 Next steps:${NC}" -echo "1. Verify the bot is working correctly" -echo "2. Check monitoring dashboards" -echo "3. Remove old systemctl service file if no longer needed" -echo "4. Update any external monitoring/alerting systems" diff --git a/scripts/start_docker.sh b/scripts/start_docker.sh deleted file mode 100755 index 433eebf..0000000 --- a/scripts/start_docker.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -echo "🐍 Запуск Telegram Bot с Python 3.9 (стандартная версия)..." -echo "" - -echo "🔧 Сборка Docker образа с Python 3.9..." -make build - -echo "" -echo "🚀 Запуск сервисов..." -make up - -echo "" -echo "🐍 Проверка версии Python в контейнере..." -make check-python - -echo "" -echo "📦 Проверка установленных пакетов..." -docker exec telegram-bot .venv/bin/pip list - -echo "" -echo "✅ Сервисы успешно запущены!" -echo "" -echo "📝 Полезные команды:" -echo " Логи бота: make logs-bot" -echo " Статус: make status" -echo " Остановка: make stop" -echo " Перезапуск: make restart" -echo "" -echo "📊 Мониторинг:" -echo " Prometheus: http://localhost:9090" -echo " Grafana: http://localhost:3000 (admin/admin)" diff --git a/voice_bot/README.md b/voice_bot/README.md new file mode 100644 index 0000000..76305bd --- /dev/null +++ b/voice_bot/README.md @@ -0,0 +1,111 @@ +# Voice Bot - Архитектура + +## Обзор + +Voice Bot был рефакторен в соответствии с принципами чистой архитектуры, следуя паттернам, используемым в `helper_bot`. + +## Структура проекта + +``` +voice_bot/ +├── handlers/ +│ ├── __init__.py # Экспорт всех модулей +│ ├── constants.py # Константы и сообщения +│ ├── dependencies.py # Dependency injection и middleware +│ ├── exceptions.py # Кастомные исключения +│ ├── services.py # Бизнес-логика +│ ├── utils.py # Вспомогательные функции +│ ├── voice_handler.py # Обработчики голосовых сообщений +│ └── callback_handler.py # Обработчики callback'ов +├── keyboards/ +│ └── keyboards.py # Клавиатуры +├── utils/ +│ └── helper_func.py # Устаревшие функции (для совместимости) +├── main.py # Точка входа +└── README.md # Этот файл +``` + +## Принципы архитектуры + +### 1. Разделение ответственности +- **Handlers** - только обработка событий и координация +- **Services** - бизнес-логика и операции с данными +- **Utils** - вспомогательные функции +- **Constants** - константы и сообщения + +### 2. Dependency Injection +- Использование `VoiceBotMiddleware` для внедрения зависимостей +- Типизированные зависимости `BotDB` и `Settings` +- Автоматическое получение экземпляров через `get_global_instance()` + +### 3. Обработка ошибок +- Кастомные исключения для разных типов ошибок +- Логирование всех ошибок +- Graceful fallback для пользователей + +### 4. Константы +- Все строки и значения вынесены в `constants.py` +- Легко изменять сообщения и настройки +- Централизованное управление конфигурацией + +## Основные компоненты + +### VoiceBotService +Основной сервис для работы с голосовыми сообщениями: +- Отправка приветственных сообщений +- Управление аудио файлами +- Работа с базой данных + +### AudioFileService +Сервис для работы с аудио файлами: +- Генерация имен файлов +- Сохранение в базу данных +- Скачивание и сохранение файлов + +### VoiceBotMiddleware +Middleware для dependency injection: +- Автоматическое внедрение зависимостей +- Обработка ошибок +- Совместимость с MagicData + +## Использование + +### Импорт сервисов +```python +from voice_bot.handlers.services import VoiceBotService, AudioFileService +from voice_bot.handlers.utils import get_last_message_text +``` + +### Использование в handlers +```python +@voice_router.message(Command("start")) +async def start(message: types.Message, bot_db: BotDB, settings: Settings): + voice_service = VoiceBotService(bot_db, settings) + await voice_service.send_welcome_messages(message, user_emoji) +``` + +### Обработка ошибок +```python +try: + result = voice_service.get_random_audio(user_id) +except AudioProcessingError as e: + logger.error(f"Ошибка при получении аудио: {e}") + # Обработка ошибки +``` + +## Миграция + +Для использования новой архитектуры: + +1. Замените прямые вызовы функций на использование сервисов +2. Используйте dependency injection вместо глобальных переменных +3. Обрабатывайте исключения через кастомные классы +4. Используйте константы вместо хардкода строк + +## Преимущества новой архитектуры + +- **Тестируемость** - легко создавать моки и тесты +- **Поддерживаемость** - четкое разделение ответственности +- **Расширяемость** - легко добавлять новые функции +- **Читаемость** - понятная структура кода +- **Переиспользование** - сервисы можно использовать в разных местах diff --git a/voice_bot/handlers/__init__.py b/voice_bot/handlers/__init__.py index e69de29..cdd54de 100644 --- a/voice_bot/handlers/__init__.py +++ b/voice_bot/handlers/__init__.py @@ -0,0 +1,30 @@ +from .voice_handler import voice_router +from .callback_handler import callback_router +from .dependencies import VoiceBotMiddleware, BotDB, Settings +from .exceptions import VoiceBotError, VoiceMessageError, AudioProcessingError, DatabaseError, FileOperationError +from .services import VoiceBotService, AudioFileService, VoiceMessage +from .utils import ( + format_time_ago, plural_time, get_last_message_text, + validate_voice_message, get_user_emoji_safe +) + +__all__ = [ + 'voice_router', + 'callback_router', + 'VoiceBotMiddleware', + 'BotDB', + 'Settings', + 'VoiceBotError', + 'VoiceMessageError', + 'AudioProcessingError', + 'DatabaseError', + 'FileOperationError', + 'VoiceBotService', + 'AudioFileService', + 'VoiceMessage', + 'format_time_ago', + 'plural_time', + 'get_last_message_text', + 'validate_voice_message', + 'get_user_emoji_safe' +] diff --git a/voice_bot/handlers/callback_handler.py b/voice_bot/handlers/callback_handler.py index a969a98..b6784de 100644 --- a/voice_bot/handlers/callback_handler.py +++ b/voice_bot/handlers/callback_handler.py @@ -1,68 +1,71 @@ import time from datetime import datetime -from pathlib import Path from aiogram import Router, F from aiogram.types import CallbackQuery -from helper_bot.utils.base_dependency_factory import BaseDependencyFactory +from voice_bot.handlers.constants import CALLBACK_SAVE, CALLBACK_DELETE, VOICE_USERS_DIR +from voice_bot.handlers.dependencies import VoiceBotMiddleware, BotDB +from voice_bot.handlers.services import AudioFileService +from logs.custom_logger import logger callback_router = Router() -bdf = BaseDependencyFactory() - -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -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() +# Middleware +callback_router.callback_query.middleware(VoiceBotMiddleware()) -@callback_router.callback_query( - F.data == "save" -) -async def save_voice_message(call: CallbackQuery): - file_name = '' - file_id = 1 - user_id = BotDB.get_user_id_by_message_id_for_voice_bot(call.message.message_id) - # Проверяем что запись о файле есть в базе данных - is_having_audio_from_user = BotDB.get_last_user_audio_record(user_id=user_id) - if is_having_audio_from_user is False: - # Если нет, то генерируем имя файла - file_name = f'message_from_{user_id}_number_{file_id}' - else: - # Иначе берем последнюю запись из БД, добавляем к ней 1, и создаем новую запись - file_name = BotDB.get_path_for_audio_record(user_id=user_id) - file_id = BotDB.get_id_for_audio_record(user_id) + 1 - path = Path(f'voice_users/{file_name}.ogg') - if path.exists(): - file_name = f'message_from_{user_id}_number_{file_id}' - else: - pass - # Собираем инфо о сообщении - time_UTC = int(time.time()) - date_added = datetime.fromtimestamp(time_UTC) - - # Сохраняем в базку - BotDB.add_audio_record(file_name, user_id, date_added, 0, file_id) - - file_info = await call.message.bot.get_file(file_id=call.message.voice.file_id) - downloaded_file = await call.message.bot.download_file(file_path=file_info.file_path) - with open(f'voice_users/{file_name}.ogg', 'wb') as new_file: - new_file.write(downloaded_file.read()) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - await call.answer(text='Сохранено!', cache_time=3) +@callback_router.callback_query(F.data == CALLBACK_SAVE) +async def save_voice_message(call: CallbackQuery, bot_db: BotDB): + try: + # Создаем сервис для работы с аудио файлами + audio_service = AudioFileService(bot_db) + + # Получаем ID пользователя из базы + user_id = bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_id) + + # Генерируем имя файла + file_name = audio_service.generate_file_name(user_id) + + # Собираем инфо о сообщении + time_UTC = int(time.time()) + date_added = datetime.fromtimestamp(time_UTC) + + # Определяем file_id + file_id = 1 + if bot_db.get_last_user_audio_record(user_id=user_id): + file_id = bot_db.get_id_for_audio_record(user_id) + 1 + + # Сохраняем в базу данных + audio_service.save_audio_file(file_name, user_id, file_id, date_added) + + # Скачиваем и сохраняем файл + await audio_service.download_and_save_audio(call.bot, call.message.message_id, file_name) + + # Удаляем сообщение из предложки + await call.bot.delete_message( + chat_id=bot_db.settings['Telegram']['group_for_posts'], + message_id=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) -@callback_router.callback_query( - F.data == "delete" -) -async def delete_voice_message(call: CallbackQuery): - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - await call.answer(text='Удалено!', cache_time=3) +@callback_router.callback_query(F.data == CALLBACK_DELETE) +async def delete_voice_message(call: CallbackQuery, bot_db: BotDB): + try: + # Удаляем сообщение из предложки + await call.bot.delete_message( + chat_id=bot_db.settings['Telegram']['group_for_posts'], + message_id=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) diff --git a/voice_bot/handlers/constants.py b/voice_bot/handlers/constants.py new file mode 100644 index 0000000..716131b --- /dev/null +++ b/voice_bot/handlers/constants.py @@ -0,0 +1,56 @@ +# 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" + +# Button texts +BTN_SPEAK = "🎤Высказаться" +BTN_LISTEN = "🎧Послушать" + +# Callback data +CALLBACK_SAVE = "save" +CALLBACK_DELETE = "delete" + +# File paths +VOICE_USERS_DIR = "voice_users" +STICK_DIR = "Stick" +STICK_PATTERN = "Hello_*" + +# Messages +WELCOME_MESSAGE = "Привет." +DESCRIPTION_MESSAGE = "Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска" +ANALOGY_MESSAGE = "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится.." +RULES_MESSAGE = "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, хотя бы на 5-10 секунд" +ANONYMITY_MESSAGE = "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)" +SUGGESTION_MESSAGE = "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)" +EMOJI_INFO_MESSAGE = "Любые войсы будут помечены эмоджи. Твой эмоджи - {emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)" +HELP_INFO_MESSAGE = "Так же можешь ознакомиться с инструкцией к боту по команде /help" +FINAL_MESSAGE = "Ну всё, достаточно инструкций. записывайся! Микрофон твой - 🎤" + +# Help message +HELP_MESSAGE = "Скорее всего ответы на твои вопросы есть здесь, ознакомься: https://telegra.ph/Instrukciya-k-botu-Golosa-Bijsk-10-11-2\nЕсли это не поможет, пиши в личку: @Kerrad1" + +# Success messages +VOICE_SAVED_MESSAGE = "Окей, сохранил!👌" +LISTENINGS_CLEARED_MESSAGE = "Прослушивания очищены. Можешь начать слушать заново🤗" +NO_AUDIO_MESSAGE = "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится" + +# Error messages +UNKNOWN_CONTENT_MESSAGE = "Я тебя не понимаю🤷‍♀️ запиши голосовое" +RECORD_VOICE_MESSAGE = "Хорошо, теперь пришли мне свое голосовое сообщение" + +# 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 diff --git a/voice_bot/handlers/dependencies.py b/voice_bot/handlers/dependencies.py new file mode 100644 index 0000000..e733b06 --- /dev/null +++ b/voice_bot/handlers/dependencies.py @@ -0,0 +1,48 @@ +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 logs.custom_logger import logger + + +class VoiceBotMiddleware(BaseMiddleware): + """Middleware для voice_bot с dependency injection""" + + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + try: + # Вызываем хендлер с data + return await handler(event, data) + except TypeError as e: + if "missing 1 required positional argument: 'data'" in str(e): + logger.error(f"Ошибка в VoiceBotMiddleware: {e}. Хендлер не принимает параметр 'data'") + # Пытаемся вызвать хендлер без data (для совместимости с MagicData) + return await handler(event) + else: + logger.error(f"TypeError в VoiceBotMiddleware: {e}") + raise + except Exception as e: + logger.error(f"Неожиданная ошибка в VoiceBotMiddleware: {e}") + raise + + +# Dependency providers +def get_bot_db(): + """Провайдер для получения экземпляра БД""" + bdf = get_global_instance() + return bdf.get_db() + + +def get_settings(): + """Провайдер для получения настроек""" + bdf = get_global_instance() + return bdf.settings + + +# Type aliases for dependency injection +BotDB = Annotated[object, get_bot_db()] +Settings = Annotated[dict, get_settings()] diff --git a/voice_bot/handlers/exceptions.py b/voice_bot/handlers/exceptions.py new file mode 100644 index 0000000..ac43ef8 --- /dev/null +++ b/voice_bot/handlers/exceptions.py @@ -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 diff --git a/voice_bot/handlers/services.py b/voice_bot/handlers/services.py new file mode 100644 index 0000000..5d41a44 --- /dev/null +++ b/voice_bot/handlers/services.py @@ -0,0 +1,263 @@ +import random +import asyncio +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple + +from aiogram.types import FSInputFile + +from voice_bot.handlers.exceptions import VoiceMessageError, AudioProcessingError, DatabaseError, FileOperationError +from voice_bot.handlers.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 + + +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 + + 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 + + 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="Привет.", + 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="Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска", + 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="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, хотя бы на 5-10 секунд", + 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"Любые войсы будут помечены эмоджи. Твой эмоджи - {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="Ну всё, достаточно инструкций. записывайся! Микрофон твой - 🎤", + 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}") + + def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]: + """Получить случайное аудио для прослушивания""" + try: + check_audio = 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 = self.bot_db.get_user_id_by_file_name(audio_for_user) + date_added = self.bot_db.get_date_by_file_name(audio_for_user) + user_emoji = self.bot_db.check_emoji_for_user(user_id_author) + + return audio_for_user, date_added, user_emoji + + except Exception as e: + logger.error(f"Ошибка при получении случайного аудио: {e}") + raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}") + + def mark_audio_as_listened(self, file_name: str, user_id: int) -> None: + """Пометить аудио как прослушанное""" + try: + self.bot_db.mark_listened_audio(file_name, user_id=user_id) + except Exception as e: + logger.error(f"Ошибка при пометке аудио как прослушанного: {e}") + raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}") + + def clear_user_listenings(self, user_id: int) -> None: + """Очистить прослушивания пользователя""" + try: + self.bot_db.delete_listen_count_for_user(user_id) + except Exception as e: + logger.error(f"Ошибка при очистке прослушиваний: {e}") + raise DatabaseError(f"Не удалось очистить прослушивания: {e}") + + def get_remaining_audio_count(self, user_id: int) -> int: + """Получить количество оставшихся непрослушанных аудио""" + try: + check_audio = 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}") + + def _get_main_keyboard(self): + """Получить основную клавиатуру""" + from voice_bot.keyboards.keyboards import get_main_keyboard + return get_main_keyboard() + + 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 + + def generate_file_name(self, user_id: int) -> str: + """Сгенерировать имя файла для аудио""" + try: + # Проверяем есть ли запись о файле в базе данных + is_having_audio_from_user = self.bot_db.get_last_user_audio_record(user_id=user_id) + + if is_having_audio_from_user is False: + # Если нет, то генерируем имя файла + file_name = f'message_from_{user_id}_number_1' + else: + # Иначе берем последнюю запись из БД, добавляем к ней 1 + file_name = self.bot_db.get_path_for_audio_record(user_id=user_id) + file_id = self.bot_db.get_id_for_audio_record(user_id) + 1 + path = Path(f'voice_users/{file_name}.ogg') + + if path.exists(): + file_name = f'message_from_{user_id}_number_{file_id}' + + return file_name + + except Exception as e: + logger.error(f"Ошибка при генерации имени файла: {e}") + raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}") + + def save_audio_file(self, file_name: str, user_id: int, file_id: int, date_added: datetime) -> None: + """Сохранить информацию об аудио файле в базу данных""" + try: + self.bot_db.add_audio_record(file_name, user_id, date_added, 0, file_id) + except Exception as e: + logger.error(f"Ошибка при сохранении аудио файла в БД: {e}") + raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}") + + async def download_and_save_audio(self, bot, message_id: int, file_name: str) -> None: + """Скачать и сохранить аудио файл""" + try: + # Получаем информацию о файле + file_info = await bot.get_file(file_id=bot.get_message(message_id).voice.file_id) + downloaded_file = await bot.download_file(file_path=file_info.file_path) + + # Сохраняем файл + with open(f'voice_users/{file_name}.ogg', 'wb') as new_file: + new_file.write(downloaded_file.read()) + + except Exception as e: + logger.error(f"Ошибка при скачивании и сохранении аудио: {e}") + raise FileOperationError(f"Не удалось скачать и сохранить аудио: {e}") diff --git a/voice_bot/handlers/utils.py b/voice_bot/handlers/utils.py new file mode 100644 index 0000000..6b26c7b --- /dev/null +++ b/voice_bot/handlers/utils.py @@ -0,0 +1,95 @@ +import time +import html +from datetime import datetime +from typing import Optional + +from voice_bot.handlers.exceptions import DatabaseError +from logs.custom_logger import logger + + +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'Последнее сообщение было записано {word_minute_escaped} назад' + 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'Последнее сообщение было записано {word_hour_escaped} назад' + elif much_hour_ago > 24: + word_day = plural_time(3, much_days_ago) + # Экранируем потенциально проблемные символы + word_day_escaped = html.escape(word_day) + message_with_date = f'Последнее сообщение было записано {word_day_escaped} назад' + + 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] + + +def get_last_message_text(bot_db) -> Optional[str]: + """Получить текст сообщения о времени последней записи""" + try: + date_from_db = bot_db.last_date_audio() + return format_time_ago(date_from_db) + except Exception as e: + logger.error(f"Не удалось получить дату последнего сообщения - {e}") + return None + + +def validate_voice_message(message) -> bool: + """Проверить валидность голосового сообщения""" + return message.content_type == 'voice' + + +def get_user_emoji_safe(bot_db, user_id: int) -> str: + """Безопасно получить эмодзи пользователя""" + try: + user_emoji = bot_db.check_emoji_for_user(user_id) + return user_emoji if user_emoji else "😊" + except Exception as e: + logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}") + return "😊" diff --git a/voice_bot/handlers/voice_handler.py b/voice_bot/handlers/voice_handler.py index 99524af..0d1fcfb 100644 --- a/voice_bot/handlers/voice_handler.py +++ b/voice_bot/handlers/voice_handler.py @@ -1,4 +1,3 @@ -import random import asyncio from datetime import datetime from pathlib import Path @@ -10,227 +9,207 @@ from aiogram.types import FSInputFile from helper_bot.filters.main import ChatTypeFilter from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware -from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.helper_func import update_user_info, check_user_emoji, send_voice_message from logs.custom_logger import logger +from voice_bot.handlers.constants import * +from voice_bot.handlers.dependencies import VoiceBotMiddleware, BotDB, Settings +from voice_bot.handlers.services import VoiceBotService +from voice_bot.handlers.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe from voice_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice -from voice_bot.utils.helper_func import last_message voice_router = Router() -bdf = get_global_instance() - -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -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() +# Middleware +voice_router.message.middleware(VoiceBotMiddleware()) voice_router.message.middleware(BlacklistMiddleware()) @voice_router.message( ChatTypeFilter(chat_type=["private"]), - Command("restart") + Command(CMD_RESTART) ) -async def restart_function(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) - await update_user_info('voice', message) +async def restart_function(message: types.Message, state: FSMContext, bot_db: BotDB): + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) + await update_user_info(VOICE_BOT_NAME, message) check_user_emoji(message) markup = get_main_keyboard() - await message.answer(text='Я перезапущен!', - reply_markup=markup) - await state.set_state('START') + await message.answer(text='Я перезапущен!', reply_markup=markup) + await state.set_state(STATE_START) @voice_router.message( ChatTypeFilter(chat_type=["private"]), - Command("emoji") + Command(CMD_EMOJI) ) -async def handle_emoji_message(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) +async def handle_emoji_message(message: types.Message, state: FSMContext, bot_db: BotDB): + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) user_emoji = check_user_emoji(message) - await state.set_state("START") + await state.set_state(STATE_START) if user_emoji is not None: await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') @voice_router.message( ChatTypeFilter(chat_type=["private"]), - Command("help") + Command(CMD_HELP) ) -async def help_function(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) - await update_user_info('voice', message) +async def help_function(message: types.Message, state: FSMContext, bot_db: BotDB): + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) + await update_user_info(VOICE_BOT_NAME, message) await message.answer( - text='Скорее всего ответы на твои вопросы есть здесь, ознакомься: https://telegra.ph/Instrukciya-k-botu-Golosa-Bijsk-10-11-2' - '\nЕсли это не поможет, пиши в личку: @Kerrad1', disable_web_page_preview=not PREVIEW_LINK) - await state.set_state('START') + text=HELP_MESSAGE, + disable_web_page_preview=not bot_db.settings['Telegram']['preview_link'] + ) + await state.set_state(STATE_START) @voice_router.message( ChatTypeFilter(chat_type=["private"]), - Command("start") + Command(CMD_START) ) -async def start(message: types.Message, state: FSMContext): - await state.set_state("START") - await message.forward(chat_id=GROUP_FOR_LOGS) - await update_user_info('voice', message) - user_emoji = check_user_emoji(message) - try: - 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"Стикер успешно получен из БД. Наименование стикера: {name_stick_hello}") - await message.answer_sticker(random_stick_hello) - await asyncio.sleep(0.3) - except Exception as e: - if LOGS: - await message.bot.send_message(IMPORTANT_LOGS, f'Отправка приветственных стикеров лажает. Ошибка: {e}') - markup = get_main_keyboard() - await message.answer(text="Привет.", parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(0.3) - await message.answer(text="Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из " - "Бийска", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(1) - await message.answer(text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не " - "узнаешь, послушал его кто-то или нет и ответить тоже не получится..", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(0.8) - await message.answer(text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, хотя " - "бы на 5-10 секунд", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(1.5) - await message.answer(text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, " - "ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы " - "выкладывать в собственные соцсети)", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(1.3) - await message.answer(text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из " - "недавно полученных или отправленных (или спеть, рассказать стихотворенье)", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(0.8) - await message.answer(text=f"Любые войсы будут помечены эмоджи. Твой эмоджи - {user_emoji}" - f"Таким эмоджи будут помечены твои сообщения для других " - f"Но другие люди не узнают кто за каким эмоджи скрывается:)", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(0.8) - await message.answer(text="Так же можешь ознакомиться с инструкцией к боту по команде /help", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) - await asyncio.sleep(0.8) - await message.answer(text="Ну всё, достаточно инструкций. записывайся! Микрофон твой - 🎤", - parse_mode='html', reply_markup=markup, - disable_web_page_preview=not PREVIEW_LINK) +async def start(message: types.Message, state: FSMContext, bot_db: BotDB, settings: Settings): + 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 = 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) @voice_router.message( ChatTypeFilter(chat_type=["private"]), - Command("refresh") + Command(CMD_REFRESH) ) -async def refresh_listen_function(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) - await update_user_info('voice', message) +async def refresh_listen_function(message: types.Message, state: FSMContext, bot_db: BotDB): + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) + await update_user_info(VOICE_BOT_NAME, message) markup = get_main_keyboard() - BotDB.delete_listen_count_for_user(message.from_user.id) + + # Очищаем прослушивания через сервис + voice_service = VoiceBotService(bot_db, bot_db.settings) + voice_service.clear_user_listenings(message.from_user.id) + await message.answer( - text='Прослушивания очищены. Можешь начать слушать заново🤗', disable_web_page_preview=not PREVIEW_LINK, - markup=markup) - await state.set_state('START') + text=LISTENINGS_CLEARED_MESSAGE, + disable_web_page_preview=not bot_db.settings['Telegram']['preview_link'], + reply_markup=markup + ) + await state.set_state(STATE_START) @voice_router.message( - StateFilter("START"), + StateFilter(STATE_START), ChatTypeFilter(chat_type=["private"]), - F.text == '🎤Высказаться' + F.text == BTN_SPEAK ) -async def standup_write(message: types.Message, state: FSMContext): - await message.forward(chat_id=GROUP_FOR_LOGS) +async def standup_write(message: types.Message, state: FSMContext, bot_db: BotDB): + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) markup = types.ReplyKeyboardRemove() - await message.answer(text='Хорошо, теперь пришли мне свое голосовое сообщение', reply_markup=markup) + await message.answer(text=RECORD_VOICE_MESSAGE, reply_markup=markup) + try: - message_with_date = last_message() - await message.answer(text=message_with_date, parse_mode="html") + message_with_date = 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'Не удалось получить дату последнего сообщения - {e}') - await state.set_state('STANDUP_WRITE') + + await state.set_state(STATE_STANDUP_WRITE) @voice_router.message( - StateFilter("STANDUP_WRITE"), + StateFilter(STATE_STANDUP_WRITE), ChatTypeFilter(chat_type=["private"]), ) -async def suggest_voice(message: types.Message, state: FSMContext): +async def suggest_voice(message: types.Message, state: FSMContext, bot_db: BotDB): logger.info( - f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}") - await message.forward(chat_id=GROUP_FOR_LOGS) + f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}" + ) + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) markup = get_main_keyboard() - if message.content_type == 'voice': + + if validate_voice_message(message): markup_for_voice = get_reply_keyboard_for_voice() + # Отправляем аудио в приватный канал - sent_message = await send_voice_message(GROUP_FOR_POST, message, - message.voice.file_id, markup_for_voice) + sent_message = await send_voice_message( + bot_db.settings['Telegram']['group_for_posts'], + message, + message.voice.file_id, + markup_for_voice + ) # Сохраняем в базу инфо о посте - BotDB.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id) + bot_db.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id) # Отправляем юзеру ответ и возвращаем его в меню - await message.answer(text='Окей, сохранил!👌', reply_markup=markup) - await state.set_state('START') + await message.answer(text=VOICE_SAVED_MESSAGE, reply_markup=markup) + await state.set_state(STATE_START) else: - # TODO: Если пришлют фото, он не работает - await message.forward(chat_id=GROUP_FOR_LOGS) - await message.answer(text='Я тебя не понимаю🤷‍♀️ запиши голосовое', reply_markup=markup) - await state.set_state('STANDUP_WRITE') + await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs']) + await message.answer(text=UNKNOWN_CONTENT_MESSAGE, reply_markup=markup) + await state.set_state(STATE_STANDUP_WRITE) @voice_router.message( - StateFilter("START"), + StateFilter(STATE_START), ChatTypeFilter(chat_type=["private"]), - F.text == '🎧Послушать' + F.text == BTN_LISTEN ) -async def standup_listen_audio(message: types.Message): - check_audio = BotDB.check_listen_audio(user_id=message.from_user.id) - list_audio = list(check_audio) +async def standup_listen_audio(message: types.Message, bot_db: BotDB): markup = get_main_keyboard() - if not list_audio: - await message.answer(text='Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится', - reply_markup=markup) - try: - message_with_date = last_message() - await message.answer(text=message_with_date, parse_mode="html") - except Exception as e: - logger.error(f'Не удалось получить последнюю дату {e}') - else: - # Получаем ссылку на аудио сообщение пользователя - number_element = random.randint(0, len(list_audio) - 1) - audio_for_user = check_audio[number_element] - - # Получаем автора записи + эмодзи по нему - user_id = BotDB.get_user_id_by_file_name(audio_for_user) - date_added = BotDB.get_date_by_file_name(audio_for_user) - user_emoji = BotDB.check_emoji_for_user(user_id) - - path = Path(f'voice_users/{audio_for_user}.ogg') + + # Создаем сервис для работы с аудио + voice_service = VoiceBotService(bot_db, bot_db.settings) + + try: + # Получаем случайное аудио + audio_data = voice_service.get_random_audio(message.from_user.id) + + if not audio_data: + await message.answer(text=NO_AUDIO_MESSAGE, reply_markup=markup) + try: + message_with_date = 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'Не удалось получить последнюю дату {e}') + return + + audio_for_user, date_added, user_emoji = audio_data + + # Получаем путь к файлу + path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg') voice = FSInputFile(path) # Маркируем сообщение как прослушанное - BotDB.mark_listened_audio(audio_for_user, user_id=message.from_user.id) + voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id) # Формируем подпись if user_emoji: caption = f'{user_emoji}\nДата записи: {date_added}' else: caption = f'Дата записи: {date_added}' - await message.bot.send_voice(chat_id=message.chat.id, voice=voice, caption=caption, reply_markup=markup) - await message.answer(text=f'Осталось непрослушанных: {len(check_audio) - 1}', reply_markup=markup) + + await message.bot.send_voice( + chat_id=message.chat.id, + voice=voice, + caption=caption, + reply_markup=markup + ) + + # Получаем количество оставшихся аудио + remaining_count = voice_service.get_remaining_audio_count(message.from_user.id) - 1 + await message.answer( + text=f'Осталось непрослушанных: {remaining_count}', + reply_markup=markup + ) + + except Exception as e: + logger.error(f"Ошибка при прослушивании аудио: {e}") + await message.answer( + text="Произошла ошибка при получении аудио. Попробуйте позже.", + reply_markup=markup + ) diff --git a/voice_bot/main.py b/voice_bot/main.py index d0a25db..d7642f0 100644 --- a/voice_bot/main.py +++ b/voice_bot/main.py @@ -12,8 +12,7 @@ from aiogram.client.default import DefaultBotProperties from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.strategy import FSMStrategy -from voice_bot.handlers.callback_handler import callback_router -from voice_bot.handlers.voice_handler import voice_router +from voice_bot.handlers import voice_router, callback_router async def start_bot(bdf): @@ -22,7 +21,12 @@ async def start_bot(bdf): parse_mode='HTML', link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] )) + dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) - dp.include_routers(voice_router, callback_router) + + # Подключаем роутеры + dp.include_router(voice_router) + dp.include_router(callback_router) + await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, skip_updates=True) diff --git a/voice_bot/tests/test_voice_bot_architecture.py b/voice_bot/tests/test_voice_bot_architecture.py new file mode 100644 index 0000000..6a577bd --- /dev/null +++ b/voice_bot/tests/test_voice_bot_architecture.py @@ -0,0 +1,232 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch +from datetime import datetime + +from voice_bot.handlers.services import VoiceBotService, AudioFileService +from voice_bot.handlers.exceptions import VoiceMessageError, AudioProcessingError +from voice_bot.handlers.utils import format_time_ago, plural_time + + +class TestVoiceBotService: + """Тесты для VoiceBotService""" + + @pytest.fixture + def mock_bot_db(self): + """Мок для базы данных""" + mock_db = Mock() + mock_db.settings = { + 'Settings': {'logs': True}, + 'Telegram': {'important_logs': 'test_chat_id'} + } + return mock_db + + @pytest.fixture + def mock_settings(self): + """Мок для настроек""" + return { + 'Settings': {'logs': True}, + 'Telegram': {'preview_link': True} + } + + @pytest.fixture + def voice_service(self, mock_bot_db, mock_settings): + """Экземпляр VoiceBotService для тестов""" + return VoiceBotService(mock_bot_db, mock_settings) + + @pytest.mark.asyncio + async def test_get_welcome_sticker_success(self, voice_service, mock_settings): + """Тест успешного получения стикера""" + with patch('pathlib.Path.rglob') as mock_rglob: + mock_rglob.return_value = ['/path/to/sticker1.tgs', '/path/to/sticker2.tgs'] + + sticker = await voice_service.get_welcome_sticker() + + assert sticker is not None + mock_rglob.assert_called_once() + + @pytest.mark.asyncio + async def test_get_welcome_sticker_no_stickers(self, voice_service, mock_settings): + """Тест получения стикера когда их нет""" + with patch('pathlib.Path.rglob') as mock_rglob: + mock_rglob.return_value = [] + + sticker = await voice_service.get_welcome_sticker() + + assert sticker is None + + def test_get_random_audio_success(self, voice_service, mock_bot_db): + """Тест успешного получения случайного аудио""" + mock_bot_db.check_listen_audio.return_value = ['audio1', 'audio2'] + mock_bot_db.get_user_id_by_file_name.return_value = 123 + mock_bot_db.get_date_by_file_name.return_value = '2025-01-01 12:00:00' + mock_bot_db.check_emoji_for_user.return_value = '😊' + + result = voice_service.get_random_audio(456) + + assert result is not None + assert len(result) == 3 + assert result[0] in ['audio1', 'audio2'] + assert result[1] == '2025-01-01 12:00:00' + assert result[2] == '😊' + + def test_get_random_audio_no_audio(self, voice_service, mock_bot_db): + """Тест получения аудио когда их нет""" + mock_bot_db.check_listen_audio.return_value = [] + + result = voice_service.get_random_audio(456) + + assert result is None + + def test_mark_audio_as_listened_success(self, voice_service, mock_bot_db): + """Тест успешной пометки аудио как прослушанного""" + voice_service.mark_audio_as_listened('test_audio', 123) + + mock_bot_db.mark_listened_audio.assert_called_once_with('test_audio', user_id=123) + + def test_clear_user_listenings_success(self, voice_service, mock_bot_db): + """Тест успешной очистки прослушиваний""" + voice_service.clear_user_listenings(123) + + mock_bot_db.delete_listen_count_for_user.assert_called_once_with(123) + + +class TestAudioFileService: + """Тесты для AudioFileService""" + + @pytest.fixture + def mock_bot_db(self): + """Мок для базы данных""" + return Mock() + + @pytest.fixture + def audio_service(self, mock_bot_db): + """Экземпляр AudioFileService для тестов""" + return AudioFileService(mock_bot_db) + + def test_generate_file_name_first_audio(self, audio_service, mock_bot_db): + """Тест генерации имени для первого аудио пользователя""" + mock_bot_db.get_last_user_audio_record.return_value = False + + file_name = audio_service.generate_file_name(123) + + assert file_name == 'message_from_123_number_1' + + def test_generate_file_name_subsequent_audio(self, audio_service, mock_bot_db): + """Тест генерации имени для последующих аудио пользователя""" + mock_bot_db.get_last_user_audio_record.return_value = True + mock_bot_db.get_path_for_audio_record.return_value = 'existing_file' + mock_bot_db.get_id_for_audio_record.return_value = 5 + + with patch('pathlib.Path.exists') as mock_exists: + mock_exists.return_value = True + + file_name = audio_service.generate_file_name(123) + + assert file_name == 'message_from_123_number_6' + + def test_save_audio_file_success(self, audio_service, mock_bot_db): + """Тест успешного сохранения аудио файла в БД""" + file_name = 'test_file' + user_id = 123 + file_id = 1 + date_added = datetime.now() + + audio_service.save_audio_file(file_name, user_id, file_id, date_added) + + mock_bot_db.add_audio_record.assert_called_once_with( + file_name, user_id, date_added, 0, file_id + ) + + +class TestUtils: + """Тесты для утилит""" + + def test_plural_time_minutes(self): + """Тест множественного числа для минут""" + assert plural_time(1, 1) == '1 минуту' + assert plural_time(1, 2) == '2 минуты' + assert plural_time(1, 5) == '5 минут' + assert plural_time(1, 11) == '11 минут' + assert plural_time(1, 21) == '21 минуту' + + def test_plural_time_hours(self): + """Тест множественного числа для часов""" + assert plural_time(2, 1) == '1 час' + assert plural_time(2, 2) == '2 часа' + assert plural_time(2, 5) == '5 часов' + assert plural_time(2, 11) == '11 часов' + assert plural_time(2, 21) == '21 час' + + def test_plural_time_days(self): + """Тест множественного числа для дней""" + assert plural_time(3, 1) == '1 день' + assert plural_time(3, 2) == '2 дня' + assert plural_time(3, 5) == '5 дней' + assert plural_time(3, 11) == '11 дней' + assert plural_time(3, 21) == '21 день' + + def test_format_time_ago_minutes(self): + """Тест форматирования времени для минут""" + from datetime import datetime, timedelta + + # Создаем время 30 минут назад + past_time = datetime.now() - timedelta(minutes=30) + time_str = past_time.strftime("%Y-%m-%d %H:%M:%S") + + result = format_time_ago(time_str) + + assert '30 минут' in result + assert 'назад' in result + + def test_format_time_ago_hours(self): + """Тест форматирования времени для часов""" + from datetime import datetime, timedelta + + # Создаем время 2 часа назад + past_time = datetime.now() - timedelta(hours=2) + time_str = past_time.strftime("%Y-%m-%d %H:%M:%S") + + result = format_time_ago(time_str) + + assert '2 часа' in result + assert 'назад' in result + + def test_format_time_ago_days(self): + """Тест форматирования времени для дней""" + from datetime import datetime, timedelta + + # Создаем время 3 дня назад + past_time = datetime.now() - timedelta(days=3) + time_str = past_time.strftime("%Y-%m-%d %H:%M:%S") + + result = format_time_ago(time_str) + + assert '3 дня' in result + assert 'назад' in result + + +class TestExceptions: + """Тесты для исключений""" + + def test_voice_bot_error_inheritance(self): + """Тест наследования исключений""" + assert issubclass(VoiceMessageError, VoiceBotError) + assert issubclass(AudioProcessingError, VoiceBotError) + assert issubclass(DatabaseError, VoiceBotError) + assert issubclass(FileOperationError, VoiceBotError) + + def test_exception_messages(self): + """Тест сообщений исключений""" + try: + raise VoiceMessageError("Тестовая ошибка") + except VoiceMessageError as e: + assert str(e) == "Тестовая ошибка" + + try: + raise AudioProcessingError("Ошибка обработки") + except AudioProcessingError as e: + assert str(e) == "Ошибка обработки" + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/voice_bot/utils/helper_func.py b/voice_bot/utils/helper_func.py deleted file mode 100644 index e312e27..0000000 --- a/voice_bot/utils/helper_func.py +++ /dev/null @@ -1,63 +0,0 @@ -import time -import html -from datetime import datetime - -from helper_bot.utils.base_dependency_factory import get_global_instance - -bdf = get_global_instance() - -BotDB = bdf.get_db() - - -def last_message(): - # функция с отображением сообщения "Последнее сообщение было записано" - date_from_db = BotDB.last_date_audio() - 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'Последнее сообщение было записано {word_minute_escaped} назад' - 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'Последнее сообщение было записано {word_hour_escaped} назад' - elif much_hour_ago > 24: - word_day = plural_time(3, much_days_ago) - # Экранируем потенциально проблемные символы - word_day_escaped = html.escape(word_day) - message_with_date = f'Последнее сообщение было записано {word_day_escaped} назад' - return message_with_date - - -def plural_time(type, n): - word = [] - if type == 1: - word = ['минуту', 'минуты', 'минут'] - elif type == 2: - word = ['час', 'часа', 'часов'] - elif type == 3: - word = ['день', 'дня', 'дней'] - else: - pass - - 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]