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]