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:
@@ -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
121
Makefile
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
```
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
29
helper_bot/handlers/admin/constants.py
Normal file
29
helper_bot/handlers/admin/constants.py
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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": (
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
111
voice_bot/README.md
Normal 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. Используйте константы вместо хардкода строк
|
||||||
|
|
||||||
|
## Преимущества новой архитектуры
|
||||||
|
|
||||||
|
- **Тестируемость** - легко создавать моки и тесты
|
||||||
|
- **Поддерживаемость** - четкое разделение ответственности
|
||||||
|
- **Расширяемость** - легко добавлять новые функции
|
||||||
|
- **Читаемость** - понятная структура кода
|
||||||
|
- **Переиспользование** - сервисы можно использовать в разных местах
|
||||||
@@ -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'
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
# Проверяем что запись о файле есть в базе данных
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Сохраняем в базку
|
# Получаем ID пользователя из базы
|
||||||
BotDB.add_audio_record(file_name, user_id, date_added, 0, file_id)
|
user_id = bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_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)
|
file_name = audio_service.generate_file_name(user_id)
|
||||||
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)
|
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(
|
@callback_router.callback_query(F.data == CALLBACK_DELETE)
|
||||||
F.data == "delete"
|
async def delete_voice_message(call: CallbackQuery, bot_db: BotDB):
|
||||||
)
|
try:
|
||||||
async def delete_voice_message(call: CallbackQuery):
|
# Удаляем сообщение из предложки
|
||||||
# Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки
|
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)
|
||||||
|
|||||||
56
voice_bot/handlers/constants.py
Normal file
56
voice_bot/handlers/constants.py
Normal 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
|
||||||
48
voice_bot/handlers/dependencies.py
Normal file
48
voice_bot/handlers/dependencies.py
Normal 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()]
|
||||||
23
voice_bot/handlers/exceptions.py
Normal file
23
voice_bot/handlers/exceptions.py
Normal 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
|
||||||
263
voice_bot/handlers/services.py
Normal file
263
voice_bot/handlers/services.py
Normal 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}")
|
||||||
95
voice_bot/handlers/utils.py
Normal file
95
voice_bot/handlers/utils.py
Normal 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 "😊"
|
||||||
@@ -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)
|
||||||
await message.answer(text=message_with_date, parse_mode="html")
|
if message_with_date:
|
||||||
|
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)
|
|
||||||
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)
|
voice_service = VoiceBotService(bot_db, bot_db.settings)
|
||||||
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')
|
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)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
232
voice_bot/tests/test_voice_bot_architecture.py
Normal file
232
voice_bot/tests/test_voice_bot_architecture.py
Normal 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__])
|
||||||
@@ -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]
|
|
||||||
Reference in New Issue
Block a user