Refactor project structure and remove obsolete files

- Deleted the Makefile, `README_TESTING.md`, and several deployment scripts to streamline the project.
- Updated `.dockerignore` to exclude unnecessary development files.
- Adjusted database schema comments for clarity.
- Refactored metrics handling in middleware for improved command extraction and logging.
- Enhanced command mappings for buttons and callbacks in constants for better maintainability.
- Start refactor voice bot
This commit is contained in:
2025-09-01 00:54:10 +03:00
parent 2368af3d93
commit d128e54694
25 changed files with 1175 additions and 986 deletions

View File

@@ -72,8 +72,6 @@ docker-compose*.yml
.dockerignore .dockerignore
# Development files # Development files
Makefile
start_docker.sh
*.sh *.sh
# Stickers and media # Stickers and media
@@ -94,4 +92,4 @@ Stick/
# Monitoring configs (will be mounted) # Monitoring configs (will be mounted)
prometheus.yml prometheus.yml
grafana/

121
Makefile
View File

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

View File

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

View File

@@ -1,11 +1,8 @@
-- Telegram Helper Bot Database Schema -- Telegram Helper Bot Database Schema
-- Compatible with Docker container deployment -- Compatible with Docker container deployment
-- System table for SQLite auto-increment sequences -- Note: sqlite_sequence table is automatically created by SQLite for AUTOINCREMENT fields
CREATE TABLE IF NOT EXISTS sqlite_sequence ( -- No need to create it manually
name TEXT NOT NULL,
seq INTEGER NOT NULL
);
-- Users who have listened to audio messages -- Users who have listened to audio messages
CREATE TABLE IF NOT EXISTS listen_audio_users ( CREATE TABLE IF NOT EXISTS listen_audio_users (

View File

@@ -343,7 +343,7 @@ async def test_metrics_handler(
await message.answer( await message.answer(
f"✅ Тестовые метрики записаны\n" f"✅ Тестовые метрики записаны\n"
f"📊 Активных пользователей: {active_users}\n" f"📊 Активных пользователей: {active_users}\n"
f"🔧 Проверьте Grafana дашборд" f"🔧 Проверьте Prometheus метрики"
) )
except Exception as e: except Exception as e:

View File

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

View File

@@ -1,3 +1,5 @@
from typing import Final, Dict
# Callback data constants # Callback data constants
CALLBACK_PUBLISH = "publish" CALLBACK_PUBLISH = "publish"
CALLBACK_DECLINE = "decline" CALLBACK_DECLINE = "decline"
@@ -27,3 +29,13 @@ MESSAGE_USER_BANNED_SPAM = "Ты заблокирован за спам. Дат
# Error messages # Error messages
ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user" ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user"
# Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"publish": "publish",
"decline": "decline",
"ban": "ban",
"unlock": "unlock",
"return": "return",
"page": "page"
}

View File

@@ -20,6 +20,16 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
"CONNECT_ADMIN": "📩Связаться с админами" "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
ERROR_MESSAGES: Final[Dict[str, str]] = { ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": ( "UNSUPPORTED_CONTENT": (

View File

@@ -3,7 +3,7 @@ Metrics middleware for aiogram 3.x.
Automatically collects metrics for message processing, command execution, and errors. 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 import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery from aiogram.types import TelegramObject, Message, CallbackQuery
from aiogram.enums import ChatType from aiogram.enums import ChatType
@@ -11,6 +11,18 @@ import time
import logging import logging
from ..utils.metrics import metrics 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): class MetricsMiddleware(BaseMiddleware):
"""Middleware for automatic metrics collection in aiogram handlers.""" """Middleware for automatic metrics collection in aiogram handlers."""
@@ -35,23 +47,11 @@ class MetricsMiddleware(BaseMiddleware):
if isinstance(event, Message): if isinstance(event, Message):
self.logger.info(f"📊 Processing Message event") self.logger.info(f"📊 Processing Message event")
await self._record_message_metrics(event) await self._record_message_metrics(event)
if event.text and event.text.startswith('/'): command_info = self._extract_command_info(event)
command_info = {
'command': event.text.split()[0][1:], # Remove '/' and get command name
'user_type': "user" if event.from_user else "unknown",
'handler_type': "message_handler"
}
elif isinstance(event, CallbackQuery): elif isinstance(event, CallbackQuery):
self.logger.info(f"📊 Processing CallbackQuery event") self.logger.info(f"📊 Processing CallbackQuery event")
await self._record_callback_metrics(event) await self._record_callback_metrics(event)
if event.data: command_info = self._extract_callback_command_info(event)
parts = event.data.split(':', 1)
if parts:
command_info = {
'command': parts[0],
'user_type': "user" if event.from_user else "unknown",
'handler_type': "callback_handler"
}
else: else:
self.logger.info(f"📊 Processing unknown event type: {type(event).__name__}") self.logger.info(f"📊 Processing unknown event type: {type(event).__name__}")
@@ -168,6 +168,62 @@ class MetricsMiddleware(BaseMiddleware):
"""Record callback metrics efficiently.""" """Record callback metrics efficiently."""
metrics.record_message("callback_query", "callback", "callback_handler") 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): class DatabaseMetricsMiddleware(BaseMiddleware):
"""Middleware for database operation metrics.""" """Middleware for database operation metrics."""

View File

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

View File

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

View File

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

View File

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

111
voice_bot/README.md Normal file
View File

@@ -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. Используйте константы вместо хардкода строк
## Преимущества новой архитектуры
- **Тестируемость** - легко создавать моки и тесты
- **Поддерживаемость** - четкое разделение ответственности
- **Расширяемость** - легко добавлять новые функции
- **Читаемость** - понятная структура кода
- **Переиспользование** - сервисы можно использовать в разных местах

View File

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

View File

@@ -1,68 +1,71 @@
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import CallbackQuery 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() callback_router = Router()
bdf = BaseDependencyFactory() # Middleware
callback_router.callback_query.middleware(VoiceBotMiddleware())
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()
@callback_router.callback_query( @callback_router.callback_query(F.data == CALLBACK_SAVE)
F.data == "save" async def save_voice_message(call: CallbackQuery, bot_db: BotDB):
) try:
async def save_voice_message(call: CallbackQuery): # Создаем сервис для работы с аудио файлами
file_name = '' audio_service = AudioFileService(bot_db)
file_id = 1
user_id = BotDB.get_user_id_by_message_id_for_voice_bot(call.message.message_id) # Получаем ID пользователя из базы
# Проверяем что запись о файле есть в базе данных user_id = bot_db.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 = audio_service.generate_file_name(user_id)
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()) time_UTC = int(time.time())
date_added = datetime.fromtimestamp(time_UTC) date_added = datetime.fromtimestamp(time_UTC)
# Сохраняем в базку # Определяем file_id
BotDB.add_audio_record(file_name, user_id, date_added, 0, 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
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) audio_service.save_audio_file(file_name, user_id, file_id, date_added)
with open(f'voice_users/{file_name}.ogg', 'wb') as new_file:
new_file.write(downloaded_file.read()) # Скачиваем и сохраняем файл
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.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id)
await call.answer(text='Сохранено!', cache_time=3) 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) @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) await call.answer(text='Удалено!', cache_time=3)
except Exception as e:
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text='Ошибка при удалении!', cache_time=3)

View File

@@ -0,0 +1,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 = "<b>Привет.</b>"
DESCRIPTION_MESSAGE = "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>"
ANALOGY_MESSAGE = "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится.."
RULES_MESSAGE = "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>"
ANONYMITY_MESSAGE = "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)"
SUGGESTION_MESSAGE = "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)"
EMOJI_INFO_MESSAGE = "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)"
HELP_INFO_MESSAGE = "Так же можешь ознакомиться с инструкцией к боту по команде /help"
FINAL_MESSAGE = "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤"
# Help message
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

View File

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

View File

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

View File

@@ -0,0 +1,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="<b>Привет.</b>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(STICKER_DELAY)
# Отправляем описание
await message.answer(
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_1)
# Отправляем аналогию
await message.answer(
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_2)
# Отправляем правила
await message.answer(
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_3)
# Отправляем информацию об анонимности
await message.answer(
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем предложения
await message.answer(
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию об эмодзи
await message.answer(
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию о помощи
await message.answer(
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем финальное сообщение
await message.answer(
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html',
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
)
except Exception as e:
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}")
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}")

View File

@@ -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'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>'
elif much_minutes_ago > 60 and much_hour_ago <= 24:
word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы
word_hour_escaped = html.escape(word_hour)
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>'
elif much_hour_ago > 24:
word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы
word_day_escaped = html.escape(word_day)
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>'
return message_with_date
except Exception as e:
logger.error(f"Ошибка при форматировании времени: {e}")
return None
def plural_time(type: int, n: float) -> str:
"""Форматировать множественное число для времени"""
word = []
if type == 1:
word = ['минуту', 'минуты', 'минут']
elif type == 2:
word = ['час', 'часа', 'часов']
elif type == 3:
word = ['день', 'дня', 'дней']
else:
return str(int(n))
if n % 10 == 1 and n % 100 != 11:
p = 0
elif 2 <= n % 10 <= 4 and (n % 100 < 10 or n % 100 >= 20):
p = 1
else:
p = 2
new_number = int(n)
return str(new_number) + ' ' + word[p]
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 "😊"

View File

@@ -1,4 +1,3 @@
import random
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -10,227 +9,207 @@ from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware 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 helper_bot.utils.helper_func import update_user_info, check_user_emoji, send_voice_message
from logs.custom_logger import logger 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.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice
from voice_bot.utils.helper_func import last_message
voice_router = Router() voice_router = Router()
bdf = get_global_instance() # Middleware
voice_router.message.middleware(VoiceBotMiddleware())
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()
voice_router.message.middleware(BlacklistMiddleware()) voice_router.message.middleware(BlacklistMiddleware())
@voice_router.message( @voice_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("restart") Command(CMD_RESTART)
) )
async def restart_function(message: types.Message, state: FSMContext): async def restart_function(message: types.Message, state: FSMContext, bot_db: BotDB):
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
await update_user_info('voice', message) await update_user_info(VOICE_BOT_NAME, message)
check_user_emoji(message) check_user_emoji(message)
markup = get_main_keyboard() markup = get_main_keyboard()
await message.answer(text='Я перезапущен!', await message.answer(text='Я перезапущен!', reply_markup=markup)
reply_markup=markup) await state.set_state(STATE_START)
await state.set_state('START')
@voice_router.message( @voice_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("emoji") Command(CMD_EMOJI)
) )
async def handle_emoji_message(message: types.Message, state: FSMContext): async def handle_emoji_message(message: types.Message, state: FSMContext, bot_db: BotDB):
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
user_emoji = check_user_emoji(message) user_emoji = check_user_emoji(message)
await state.set_state("START") await state.set_state(STATE_START)
if user_emoji is not None: if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML') await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
@voice_router.message( @voice_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("help") Command(CMD_HELP)
) )
async def help_function(message: types.Message, state: FSMContext): async def help_function(message: types.Message, state: FSMContext, bot_db: BotDB):
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
await update_user_info('voice', message) await update_user_info(VOICE_BOT_NAME, message)
await message.answer( await message.answer(
text='Скорее всего ответы на твои вопросы есть здесь, ознакомься: https://telegra.ph/Instrukciya-k-botu-Golosa-Bijsk-10-11-2' text=HELP_MESSAGE,
'\nЕсли это не поможет, пиши в личку: @Kerrad1', disable_web_page_preview=not PREVIEW_LINK) disable_web_page_preview=not bot_db.settings['Telegram']['preview_link']
await state.set_state('START') )
await state.set_state(STATE_START)
@voice_router.message( @voice_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("start") Command(CMD_START)
) )
async def start(message: types.Message, state: FSMContext): async def start(message: types.Message, state: FSMContext, bot_db: BotDB, settings: Settings):
await state.set_state("START") await state.set_state(STATE_START)
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info('voice', message) await update_user_info(VOICE_BOT_NAME, message)
user_emoji = check_user_emoji(message) user_emoji = get_user_emoji_safe(bot_db, message.from_user.id)
try:
name_stick_hello = list(Path('Stick').rglob('Hello_*')) # Создаем сервис и отправляем приветственные сообщения
random_stick_hello = random.choice(name_stick_hello) voice_service = VoiceBotService(bot_db, settings)
random_stick_hello = FSInputFile(path=random_stick_hello) await voice_service.send_welcome_messages(message, user_emoji)
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="<b>Привет.</b>", parse_mode='html', reply_markup=markup,
disable_web_page_preview=not PREVIEW_LINK)
await asyncio.sleep(0.3)
await message.answer(text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из "
"Бийска</i>",
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="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя "
"бы на 5-10 секунд</i>",
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"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{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="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html', reply_markup=markup,
disable_web_page_preview=not PREVIEW_LINK)
@voice_router.message( @voice_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command("refresh") Command(CMD_REFRESH)
) )
async def refresh_listen_function(message: types.Message, state: FSMContext): async def refresh_listen_function(message: types.Message, state: FSMContext, bot_db: BotDB):
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
await update_user_info('voice', message) await update_user_info(VOICE_BOT_NAME, message)
markup = get_main_keyboard() 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( await message.answer(
text='Прослушивания очищены. Можешь начать слушать заново🤗', disable_web_page_preview=not PREVIEW_LINK, text=LISTENINGS_CLEARED_MESSAGE,
markup=markup) disable_web_page_preview=not bot_db.settings['Telegram']['preview_link'],
await state.set_state('START') reply_markup=markup
)
await state.set_state(STATE_START)
@voice_router.message( @voice_router.message(
StateFilter("START"), StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == '🎤Высказаться' F.text == BTN_SPEAK
) )
async def standup_write(message: types.Message, state: FSMContext): async def standup_write(message: types.Message, state: FSMContext, bot_db: BotDB):
await message.forward(chat_id=GROUP_FOR_LOGS) await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
await message.answer(text='Хорошо, теперь пришли мне свое голосовое сообщение', reply_markup=markup) await message.answer(text=RECORD_VOICE_MESSAGE, reply_markup=markup)
try: try:
message_with_date = last_message() message_with_date = get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html") await message.answer(text=message_with_date, parse_mode="html")
except Exception as e: except Exception as e:
logger.error(f'Не удалось получить дату последнего сообщения - {e}') logger.error(f'Не удалось получить дату последнего сообщения - {e}')
await state.set_state('STANDUP_WRITE')
await state.set_state(STATE_STANDUP_WRITE)
@voice_router.message( @voice_router.message(
StateFilter("STANDUP_WRITE"), StateFilter(STATE_STANDUP_WRITE),
ChatTypeFilter(chat_type=["private"]), 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( logger.info(
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}") f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
await message.forward(chat_id=GROUP_FOR_LOGS) )
await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
markup = get_main_keyboard() markup = get_main_keyboard()
if message.content_type == 'voice':
if validate_voice_message(message):
markup_for_voice = get_reply_keyboard_for_voice() markup_for_voice = get_reply_keyboard_for_voice()
# Отправляем аудио в приватный канал # Отправляем аудио в приватный канал
sent_message = await send_voice_message(GROUP_FOR_POST, message, sent_message = await send_voice_message(
message.voice.file_id, markup_for_voice) 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 message.answer(text=VOICE_SAVED_MESSAGE, reply_markup=markup)
await state.set_state('START') await state.set_state(STATE_START)
else: else:
# TODO: Если пришлют фото, он не работает await message.forward(chat_id=bot_db.settings['Telegram']['group_for_logs'])
await message.forward(chat_id=GROUP_FOR_LOGS) await message.answer(text=UNKNOWN_CONTENT_MESSAGE, reply_markup=markup)
await message.answer(text='Я тебя не понимаю🤷‍♀️ запиши голосовое', reply_markup=markup) await state.set_state(STATE_STANDUP_WRITE)
await state.set_state('STANDUP_WRITE')
@voice_router.message( @voice_router.message(
StateFilter("START"), StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
F.text == '🎧Послушать' F.text == BTN_LISTEN
) )
async def standup_listen_audio(message: types.Message): async def standup_listen_audio(message: types.Message, bot_db: BotDB):
check_audio = BotDB.check_listen_audio(user_id=message.from_user.id)
list_audio = list(check_audio)
markup = get_main_keyboard() markup = get_main_keyboard()
if not list_audio:
await message.answer(text='Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится', # Создаем сервис для работы с аудио
reply_markup=markup) voice_service = VoiceBotService(bot_db, bot_db.settings)
try: try:
message_with_date = last_message() # Получаем случайное аудио
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") await message.answer(text=message_with_date, parse_mode="html")
except Exception as e: except Exception as e:
logger.error(f'Не удалось получить последнюю дату {e}') logger.error(f'Не удалось получить последнюю дату {e}')
else: return
# Получаем ссылку на аудио сообщение пользователя
number_element = random.randint(0, len(list_audio) - 1)
audio_for_user = check_audio[number_element]
# Получаем автора записи + эмодзи по нему audio_for_user, date_added, user_emoji = audio_data
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') # Получаем путь к файлу
path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg')
voice = FSInputFile(path) 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: if user_emoji:
caption = f'{user_emoji}\nДата записи: {date_added}' caption = f'{user_emoji}\nДата записи: {date_added}'
else: else:
caption = f'Дата записи: {date_added}' 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'Осталось непрослушанных: <b>{len(check_audio) - 1}</b>', 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'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup
)
except Exception as e:
logger.error(f"Ошибка при прослушивании аудио: {e}")
await message.answer(
text="Произошла ошибка при получении аудио. Попробуйте позже.",
reply_markup=markup
)

View File

@@ -12,8 +12,7 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy from aiogram.fsm.strategy import FSMStrategy
from voice_bot.handlers.callback_handler import callback_router from voice_bot.handlers import voice_router, callback_router
from voice_bot.handlers.voice_handler import voice_router
async def start_bot(bdf): async def start_bot(bdf):
@@ -22,7 +21,12 @@ async def start_bot(bdf):
parse_mode='HTML', parse_mode='HTML',
link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
)) ))
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) 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 bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, skip_updates=True) await dp.start_polling(bot, skip_updates=True)

View File

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

View File

@@ -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'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>'
elif much_minutes_ago > 60 and much_hour_ago <= 24:
word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы
word_hour_escaped = html.escape(word_hour)
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>'
elif much_hour_ago > 24:
word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы
word_day_escaped = html.escape(word_day)
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>'
return message_with_date
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]