78 Commits

Author SHA1 Message Date
937c54ecfb Merge pull request 'Merge pull request 'Pull Request: dev-15' (#17) from dev-15 into master' (#18) from master into dev-15
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 34s
Reviewed-on: #18
2026-02-28 21:13:26 +00:00
c3b75a0eb7 fix deploy
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 43s
2026-03-01 00:03:31 +03:00
b8428a5bac Merge pull request 'Pull Request: dev-15' (#17) from dev-15 into master
All checks were successful
CI pipeline / Test & Code Quality (pull_request) Successful in 34s
Reviewed-on: #17
2026-02-28 21:02:00 +00:00
3d6b4353f9 Refactor imports across multiple files to improve code organization and readability.
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 34s
2026-02-28 23:24:25 +03:00
d0c8dab24a fix imports
Some checks failed
CI pipeline / Test & Code Quality (push) Failing after 19s
2026-02-28 23:01:21 +03:00
31314c9c9b Добавлены методы для работы с настройками авто-модерации, включая получение и установку значений, а также переключение состояний авто-публикации и авто-отклонения. Обновлены соответствующие репозитории и обработчики для интеграции новых функций в админ-панели.
Some checks are pending
CI pipeline / Test & Code Quality (push) Waiting to run
2026-02-28 22:21:29 +03:00
b3cdadfd8e 11
Some checks failed
CI pipeline / Test & Code Quality (push) Has been cancelled
2026-02-28 21:30:16 +03:00
694cf1c106 Добавлены новые методы для получения статистики постов пользователей, информации о последних постах и количестве банов. Обновлены запросы в репозиториях для сортировки пользователей по дате бана. Исправлены вызовы функций форматирования сообщений для администраторов. Обновлены тесты для проверки новых функциональностей. 2026-02-28 21:30:08 +03:00
ANDREY KATYKHIN
e2a6944ed8 Merge pull request #16 from KerradKerridi/fix-1
Переписал почти все тесты
2026-02-03 23:45:52 +03:00
73c36061c7 one more fix 2026-02-02 00:54:23 +03:00
d87d4e492e fix linter, fix ci, fix tests 2026-02-02 00:46:44 +03:00
68041037bd Merge remote-tracking branch 'origin/master' into fix-1 2026-02-02 00:41:51 +03:00
ANDREY KATYKHIN
3933259674 Merge pull request #15 from KerradKerridi/dev-13
Dev 13
2026-02-02 00:29:07 +03:00
849a033ce9 fix 2026-02-02 00:18:44 +03:00
561c9074dd style: isort + black 2026-02-02 00:13:33 +03:00
5f66c86d99 merge 2 2026-02-02 00:12:20 +03:00
2a09971628 Merge remote-tracking branch 'origin/master' into dev-13 2026-02-02 00:12:09 +03:00
c03bd75b5e style: isort + black 2026-02-01 23:29:59 +03:00
bb95127013 test fix 2026-02-01 23:24:44 +03:00
b8249ebd47 and one more fix 2026-02-01 23:17:59 +03:00
c72c876de7 some fix again 2026-02-01 23:16:11 +03:00
49432acb24 new fix 2026-02-01 23:13:10 +03:00
5ff66993fa fix black 2026-02-01 23:05:46 +03:00
9a6ab9a045 fix isort 2026-02-01 23:04:32 +03:00
f8962225ee fix quality code 2026-02-01 23:03:23 +03:00
731e68a597 import fix 2026-02-01 22:49:25 +03:00
bba5550e15 Обновлены тесты для сервиса аудиофайлов и ограничения скорости, добавлено патчирование asyncio.sleep для проверки задержек. Исправлены комментарии и улучшена читаемость тестов. 2026-02-01 22:43:36 +03:00
a5faa4bdc6 Переписал почти все тесты
feat: улучшено логирование и обработка скорингов в PostService и RagApiClient

- Добавлены отладочные сообщения для передачи скорингов в функции обработки постов.
- Обновлено логирование успешного получения скорингов из RAG API с дополнительной информацией.
- Оптимизирована обработка скорингов в функции get_text_message для улучшения отладки.
- Обновлены тесты для проверки новых функциональных возможностей и обработки ошибок.
2026-01-30 00:55:47 +03:00
e87f4af82f добавил deployment-guide 2026-01-28 01:47:04 +03:00
ANDREY KATYKHIN
90473008bc Merge pull request #14 from KerradKerridi/dev-12
Release Notes: dev-12
2026-01-28 01:31:27 +03:00
35767c289c fix: улучшена проверка данных из RAG API в методе получения статистики
- Упрощена логика проверки наличия данных из API, убраны лишние переменные.
- Обновлен расчет общего количества примеров для корректного отображения статистики.
2026-01-28 01:02:21 +03:00
a949f7e7db refactor: перемещены CI/CD пайплайны в ветку dev-13 2026-01-28 00:29:09 +03:00
81ac65f555 feat: добавлены CI/CD пайплайны 2026-01-28 00:28:36 +03:00
7d173e3474 feat: улучшена обработка статистики RAG API в админ-панели
- Добавлена проверка наличия данных из API для отображения статуса модели и статистики векторного хранилища.
- Реализован fallback на синхронные данные, если API недоступен.
- Обновлено описание метода получения статистики в RagApiClient для уточнения использования endpoint /stats.
2026-01-28 00:23:37 +03:00
5d7b051554 feat: улучшена обработка постов и медиагрупп с добавлением статуса "declined"
- Реализовано обновление статуса постов на "declined" для одиночных сообщений и медиагрупп.
- Оптимизирована фоновая обработка постов, включая получение и обработку ML-скоров.
- Обновлены обработчики для немедленного ответа пользователю при отправке постов.
- Добавлены логирование ошибок для улучшения отладки.
2026-01-27 22:10:04 +03:00
be8af704ba feat: добавлен функционал для извлечения и отправки описания PR в Telegram
- Реализована возможность получения тела последнего объединенного PR по коммиту в GitHub Actions.
- Добавлен шаг для отправки описания PR в важные логи через Telegram.
- Обновлены тесты для проверки нового функционала и улучшения логики обработки сообщений.
2026-01-26 22:40:05 +03:00
feee7f010c refactor: обновление системы ML-скоринга и переход на RAG API
- Обновлен Dockerfile для использования Alpine вместо Slim, улучшая размер образа.
- Удален устаревший RAGService и добавлен RagApiClient для работы с внешним RAG API.
- Обновлены переменные окружения в env.example для настройки нового RAG API.
- Обновлен ScoringManager для интеграции с RagApiClient.
- Упрощена структура проекта, удалены ненужные файлы и зависимости, связанные с векторным хранилищем.
- Обновлены обработчики и функции для работы с новым API, включая получение статистики и обработку ошибок.
2026-01-26 22:03:15 +03:00
7f6f0f028c feat: интеграция ML-скоринга с использованием RAG и DeepSeek
- Обновлен Dockerfile для установки необходимых зависимостей.
- Добавлены новые переменные окружения для настройки ML-скоринга в env.example.
- Реализованы методы для получения и обновления ML-скоров в AsyncBotDB и PostRepository.
- Обновлены обработчики публикации постов для интеграции ML-скоринга.
- Добавлен новый обработчик для получения статистики ML-скоринга в админ-панели.
- Обновлены функции для форматирования сообщений с учетом ML-скоров.
2026-01-26 18:40:38 +03:00
e2b1353408 feat: добавлена система миграций БД и CI/CD пайплайны
- Создана система отслеживания миграций (MigrationRepository, таблица migrations)
- Добавлен скрипт apply_migrations.py для автоматического применения миграций
- Созданы CI/CD пайплайны (.github/workflows/ci.yml, deploy.yml)
- Обновлена документация по миграциям в database-patterns.md
- Миграции применяются автоматически при деплое в продакшн
2026-01-25 23:17:09 +03:00
07e72c4d14 Добавил my-custom-rule.mdc для агента 2026-01-25 16:55:29 +03:00
ANDREY KATYKHIN
4af649dea7 Merge pull request #13 from KerradKerridi/dev-11
Dev 11
2026-01-25 16:22:31 +03:00
c53c036751 Добавил новую инструкцию для написания документации 2026-01-25 16:19:56 +03:00
d2d7c83575 Обновлен Python до версии 3.11.9 и изменены зависимости в Dockerfile и pyproject.toml. Удалены устаревшие файлы RATE_LIMITING_SOLUTION.md и тесты для rate limiting.
Обновлены пути к библиотекам в Dockerfile для соответствия новой версии Python.
Исправлены все тесты, теперь все проходят
2026-01-25 16:07:27 +03:00
5a90591564 Добавлен асинхронный механизм обработки медиагрупп в PrivateHandlers и улучшен AlbumMiddleware для более эффективного сбора сообщений.
- Реализована фоновая обработка медиагрупп, позволяющая пользователю получать ответ сразу, пока происходит сбор сообщений.
- Введен класс `AlbumGetter` для получения полной медиагруппы с использованием событий.
- Обновлены методы в `AlbumMiddleware` для поддержки нового функционала и улучшения логики обработки сообщений.
2026-01-24 01:35:36 +03:00
0e2aef8c03 Добавлен функционал для работы с медиагруппами и улучшена обработка сообщений
- Реализованы методы для добавления связи между постами и сообщениями в `PostRepository` и `AsyncBotDB`.
- Обновлены обработчики публикации постов для корректной работы с медиагруппами, включая удаление и уведомление авторов.
- Улучшена логика обработки сообщений в `AlbumMiddleware` для более эффективного сбора медиагрупп.
- Обновлены тесты для проверки нового функционала и обработки ошибок.
2026-01-24 01:23:35 +03:00
fecac6091e Добавлен функционал для работы с S3 хранилищем и обновление контента опубликованных постов
- В `env.example` добавлены настройки для S3 хранилища.
- Обновлен файл зависимостей `requirements.txt`, добавлена библиотека `aioboto3` для работы с S3.
- В `PostRepository` и `AsyncBotDB` реализованы методы для обновления и получения контента опубликованных постов.
- Обновлены обработчики публикации постов для сохранения идентификаторов опубликованных сообщений и их контента.
- Реализована логика сохранения медиафайлов в S3 или на локальный диск в зависимости от конфигурации.
- Обновлены тесты для проверки нового функционала.
2026-01-23 23:19:16 +03:00
ANDREY KATYKHIN
42f168f329 Merge pull request #12 from KerradKerridi/dev-10
Patch Notes dev-10
2026-01-23 19:54:47 +03:00
f1ebcf453e Подготовлен скрипт миграции данных с blacklist на blacklist history 2026-01-23 18:56:30 +03:00
3b841fcbfa Добавлен функционал для отслеживания истории банов пользователей.
- Введена новая модель `BlacklistHistoryRecord` для хранения информации о банах и разблокировках.
- Обновлены методы `set_user_blacklist` и `delete_user_blacklist` в `AsyncBotDB` для логирования событий в историю.
- Обновлена схема базы данных для создания таблицы `blacklist_history` и соответствующих индексов.
- Обновлены тесты для проверки нового функционала и обработки ошибок при записи в историю.
2026-01-23 16:23:27 +03:00
7269130777 Рефакторизация процесса блокировки пользователей в обработчиках callback
- Обновлена ​​функция `process_ban_user`, теперь в качестве параметра для получения сведений о пользователе используется `bot_db`.

- Улучшена обработка ошибок в сценариях, когда пользователь не найден.

- Введен единый формат отображения информации о пользователе с помощью `format_user_info`.

- Изменено управление состоянием в соответствии с новым алгоритмом ожидания сведений о блокировке.
2026-01-23 14:02:53 +03:00
477e2666a3 Добавлено поле ban_author в модель BlacklistUser и соответствующие изменения в базе данных для отслеживания автора блокировки пользователя. Обновлены методы работы с черным списком в AsyncBotDB и BlacklistRepository, а также обработка блокировок в AdminService и BanService. Обновлены тесты для проверки новых функциональностей. 2026-01-23 13:38:48 +03:00
89022aedaf Реализован функцоинал хранения сырых текстов поста в базе данных. Оформление поста происходит непосредственно перед его отправкой в канал.
- Реализованы методы `get_post_text_and_anonymity_by_message_id` и `get_post_text_and_anonymity_by_helper_id` в `PostRepository` для получения текста поста и флага анонимности.
- Обновлена модель `TelegramPost`, добавлено поле `is_anonymous`.
- Изменена схема базы данных для включения поля `is_anonymous` в таблицу `post_from_telegram_suggest`.
- Обновлены функции публикации постов в `PostPublishService` для учета анонимности.
- Добавлены тесты для проверки новых функций и корректности работы с анонимностью.
2026-01-23 12:12:21 +03:00
c6ba90552d some fixes 2026-01-23 00:38:15 +03:00
34251507da fix problems 2026-01-23 00:37:09 +03:00
03ed2bcf4e Обновлена ​​обработка статуса медиагрупп и улучшены интеграционные тесты
- Реализовано обновление статуса медиагрупп в `PostPublishService` при отклонении медиагрупп.

- Добавлены интеграционные тесты для обновления статусов постов и медиагрупп в `test_post_repository_integration.py

- Улучшен фиктивный репозиторий в `conftest_post_repository.py` для поддержки новых методов обновления статуса.

- Обновлены существующие тесты для проверки корректной обработки статуса постов и медиагрупп.
2026-01-22 23:52:48 +03:00
09e894e48f Обновление управления статусами сообщений и схемы базы данных
- Добавлены методы в `AsyncBotDB` и `PostRepository` для обновления статусов сообщений по идентификатору сообщения и для групп медиафайлов.

- Добавлено поле `status` в модель `TelegramPost` и обновлена ​​схема базы данных, чтобы включить это поле со значением по умолчанию 'suggest'.

- Обновлен `PostPublishService` для установки статусов сообщений на 'approved' или 'declined' в процессе публикации.
2026-01-22 23:37:27 +03:00
ANDREY KATYKHIN
422c36074e Merge pull request #11 from KerradKerridi/dev-9
Dev 9
2025-09-19 13:02:40 +03:00
a0a7a47c8d Refactor Docker configuration and improve database initialization
- Updated `.dockerignore` to streamline ignored files and directories, focusing on essential components.
- Removed obsolete `Dockerfile.bot` to simplify the build process.
- Enhanced `run_helper.py` with a new `init_db` function to initialize the SQLite database if it doesn't exist, improving setup reliability.
- Removed the `/status` endpoint from `server_prometheus.py` to clean up unused functionality and improve code clarity.
2025-09-16 18:43:05 +03:00
ANDREY KATYKHIN
574d374eaa Delete .github/workflows directory 2025-09-08 23:42:20 +03:00
ANDREY KATYKHIN
3f5a6045d8 Create pylint.yml 2025-09-08 23:29:13 +03:00
31e29cdec0 Update banned users handling with async support and improved date parsing
- Modified `change_page` function in `callback_handlers.py` to use async methods for retrieving banned users and their buttons.
- Enhanced `get_banned_users_list` in `helper_func.py` to handle string timestamps and various date formats, ensuring robust date parsing.
- Added a new test case in `test_utils.py` to validate the handling of string timestamps in the banned users list retrieval.
2025-09-08 23:19:19 +03:00
5f6882d348 Implement audio record management features in AsyncBotDB and AudioRepository
- Added methods to delete audio moderation records and retrieve all audio records in async_db.py.
- Enhanced AudioRepository with functionality to delete audio records by file name and retrieve all audio message records.
- Improved logging for audio record operations to enhance monitoring and debugging capabilities.
- Updated related handlers to ensure proper integration of new audio management features.
2025-09-05 01:31:50 +03:00
ANDREY KATYKHIN
87ba7b0040 Merge pull request #10 from KerradKerridi/dev-8
Dev 8
2025-09-04 01:00:36 +03:00
fc0517c011 Enhance bot functionality with new features and improvements
- Added a new `/status` endpoint in `server_prometheus.py` to provide process status information, including uptime and resource usage metrics.
- Implemented a PID manager in `run_helper.py` to track the bot's process, improving monitoring capabilities.
- Introduced a method to delete audio moderation records in `audio_repository.py`, enhancing database management.
- Updated voice message handling in callback handlers to ensure proper deletion of audio moderation records.
- Improved error handling and logging in various services, ensuring better tracking of media processing and file downloads.
- Refactored media handling functions to streamline operations and improve code readability.
- Enhanced metrics tracking for file downloads and media processing, providing better insights into bot performance.
2025-09-04 00:46:45 +03:00
ae7bd476bb Refactor metrics handling and remove scheduler
- Removed the metrics scheduler functionality from the bot, transitioning to real-time metrics updates via middleware.
- Enhanced logging for metrics operations across various handlers to improve monitoring and debugging capabilities.
- Integrated metrics tracking for user activities and database errors, providing better insights into bot performance.
- Cleaned up code by removing obsolete comments and unused imports, improving overall readability and maintainability.
2025-09-03 19:18:04 +03:00
650acd5bce Add cancel ban process handler in admin handlers
- Introduced a new handler for canceling the ban process in `admin_handlers.py`, allowing users to easily abort ongoing ban actions.
- Enhanced error handling and logging for the cancellation process to improve user experience and debugging.
- Removed obsolete comments and cleaned up the code for better readability in the admin handlers.
- Updated metrics middleware to streamline event processing and improve logging clarity, ensuring comprehensive metrics collection for all events.
2025-09-03 17:35:51 +03:00
fe06008930 Enhance metrics handling and logging in bot
- Integrated metrics scheduler start and stop functionality in `run_helper.py` for better resource management.
- Improved logging for metrics server operations in `server_prometheus.py`, ensuring clearer error reporting and status updates.
- Updated metrics middleware to collect comprehensive metrics for all event types, enhancing monitoring capabilities.
- Added active user metrics tracking in `admin_handlers.py` to provide insights on user engagement.
- Refactored command and callback handling in `metrics_middleware.py` to improve clarity and error handling.
2025-09-03 16:16:14 +03:00
c8c7d50cbb Refactor metrics handling and improve logging
- Removed the MetricsManager initialization from `run_helper.py` to avoid duplication, as metrics are now handled in `main.py`.
- Updated logging levels in `server_prometheus.py` and `metrics_middleware.py` to use debug instead of info for less critical messages.
- Added metrics configuration to `BaseDependencyFactory` for better management of metrics settings.
- Deleted the obsolete `metrics_exporter.py` file to streamline the codebase.
- Updated various tests to reflect changes in the metrics handling and ensure proper functionality.
2025-09-03 00:33:20 +03:00
6fcecff97c Refactor voice handler and update welcome message
- Reintroduced the cancel handler registration in the voice handler for private chats, ensuring users can cancel interactions with the bot.
- Updated the welcome message in the messages utility to reflect the new voice bot access method and added instructions for using the /restart command.
- Improved clarity in the welcome message by including a link to the bot's help command and contact information for user support.
2025-09-02 22:42:33 +03:00
1ab427a7ba Enhance admin handlers with improved logging and error handling
- Added detailed logging for user ban processing in `process_ban_target` and `process_ban_reason` functions, including user data and error messages.
- Improved error handling for user input validation and database interactions.
- Updated `return_to_admin_menu` function to log user return actions.
- Enhanced media group handling in `PostPublishService` with better error logging and author ID retrieval.
- Added new button options in voice handlers and updated keyboard layouts for improved user interaction.
- Refactored album middleware to better handle media group messages and added documentation for clarity.
2025-09-02 22:20:34 +03:00
1c6a37bc12 Enhance bot functionality and refactor database interactions
- Added `ca-certificates` installation to Dockerfile for improved network security.
- Updated health check command in Dockerfile to include better timeout handling.
- Refactored `run_helper.py` to implement proper signal handling and logging during shutdown.
- Transitioned database operations to an asynchronous model in `async_db.py`, improving performance and responsiveness.
- Updated database schema to support new foreign key relationships and optimized indexing for better query performance.
- Enhanced various bot handlers to utilize async database methods, improving overall efficiency and user experience.
- Removed obsolete database and fix scripts to streamline the project structure.
2025-09-02 18:22:02 +03:00
013892dcb7 Remove obsolete configuration management and test settings files
- Deleted the `config.py` file responsible for managing bot configuration via environment variables and `.env` files, as it is no longer needed.
- Removed the `test_settings.ini` file used for testing, which contained mock configuration data.
- Cleaned up the project structure by eliminating unused files to enhance maintainability.
2025-09-01 20:30:10 +03:00
3a7b0f6219 Add voice bot welcome tracking functionality
- Implemented methods to check and mark if a user has received a welcome message from the voice bot in both async and synchronous database classes.
- Updated database schema to include a new field for tracking welcome message status.
- Enhanced voice handler to utilize the new tracking methods, improving user interaction flow and engagement metrics.
2025-09-01 19:43:46 +03:00
2d40f4496e Update voice bot functionality and clean up project structure
- Added voice message handling capabilities, including saving and deleting audio messages via callback queries.
- Refactored audio record management in the database to remove unnecessary fields and streamline operations.
- Introduced new keyboard options for voice interactions in the bot.
- Updated `.gitignore` to include voice user files for better project organization.
- Removed obsolete voice bot handler files to simplify the codebase.
2025-09-01 19:17:05 +03:00
d128e54694 Refactor project structure and remove obsolete files
- Deleted the Makefile, `README_TESTING.md`, and several deployment scripts to streamline the project.
- Updated `.dockerignore` to exclude unnecessary development files.
- Adjusted database schema comments for clarity.
- Refactored metrics handling in middleware for improved command extraction and logging.
- Enhanced command mappings for buttons and callbacks in constants for better maintainability.
- Start refactor voice bot
2025-09-01 00:54:10 +03:00
2368af3d93 Merge branch 'dev-7' into dev-8 2025-08-31 23:38:57 +03:00
98d12be67d Initial commit for dev-8 branch with all current changes 2025-08-31 15:35:42 +03:00
5c2f9e501d Enhance user activity tracking in private handlers
- Added functionality to log user messages and update user activity in the suggest router, improving user engagement metrics.
2025-08-31 11:17:49 +03:00
195 changed files with 33049 additions and 9971 deletions

View File

@@ -0,0 +1,88 @@
---
description: "Архитектурные паттерны и структура проекта Telegram бота на aiogram"
alwaysApply: true
---
# Архитектура проекта
Этот проект - Telegram бот на **aiogram 3.10.0** с четкой архитектурой и разделением ответственности.
## Структура проекта
```
helper_bot/
├── handlers/ # Обработчики событий (admin, callback, group, private, voice)
│ ├── services.py # Бизнес-логика для каждого модуля
│ ├── exceptions.py # Кастомные исключения модуля
│ └── dependencies.py # Dependency injection для модуля
├── middlewares/ # Middleware для cross-cutting concerns
├── utils/ # Утилиты и вспомогательные функции
├── keyboards/ # Клавиатуры для бота
└── filters/ # Кастомные фильтры
database/
├── repositories/ # Репозитории для работы с БД (Repository pattern)
├── models.py # Модели данных
├── base.py # Базовый класс DatabaseConnection
└── async_db.py # AsyncBotDB - основной интерфейс к БД
```
## Архитектурные паттерны
### 1. Repository Pattern
- Все операции с БД выполняются через репозитории в `database/repositories/`
- Каждая сущность имеет свой репозиторий (UserRepository, PostRepository, etc.)
- Репозитории наследуются от `DatabaseConnection` из `database/base.py`
- Используется `RepositoryFactory` для создания репозиториев
### 2. Service Layer Pattern
- Бизнес-логика вынесена в сервисы (`handlers/*/services.py`)
- Handlers только обрабатывают события и вызывают сервисы
- Сервисы работают с репозиториями через `AsyncBotDB`
### 3. Dependency Injection
- Используется `BaseDependencyFactory` для управления зависимостями
- Глобальный экземпляр доступен через `get_global_instance()`
- Зависимости внедряются через `DependenciesMiddleware`
- Для каждого модуля handlers может быть свой `dependencies.py` с фабриками
### 4. Middleware Pattern
- Middleware регистрируются в `main.py` на уровне dispatcher
- Порядок регистрации важен: DependenciesMiddleware → MetricsMiddleware → BlacklistMiddleware → RateLimitMiddleware
- Middleware обрабатывают cross-cutting concerns (логирование, метрики, rate limiting)
## Принципы
1. **Разделение ответственности**: Handlers → Services → Repositories
2. **Асинхронность**: Все операции с БД и API асинхронные
3. **Типизация**: Используются type hints везде, где возможно
4. **Логирование**: Всегда через `logs.custom_logger.logger`
5. **Метрики**: Декораторы `@track_time`, `@track_errors`, `@db_query_time` для мониторинга
## Версия Python
Проект использует **Python 3.11.9** во всех окружениях:
- Локальная разработка: Python 3.11.9 (указана в `.python-version`)
- Docker (production): Python 3.11.9-alpine (указана в `Dockerfile`)
- Минимальная версия: Python 3.11 (указана в `pyproject.toml`)
**Важно:**
- При написании кода можно использовать фичи Python 3.11
- Доступны улучшенные type hints, match/case (Python 3.10+)
- Используйте type hints везде, где возможно
- `@dataclass` доступен (Python 3.7+)
**Структура проекта:**
- Docker файлы находятся в двух местах:
- `/prod/Dockerfile` - для инфраструктуры (Python 3.11.9-alpine)
- `/prod/bots/telegram-helper-bot/Dockerfile` - для бота (Python 3.11.9-alpine)
- При обновлении версии Python нужно обновить оба Dockerfile
**Для локальной разработки:**
Рекомендуется использовать `pyenv` для установки Python 3.11.9:
```bash
pyenv install 3.11.9
pyenv local 3.11.9
```
Подробнее см. `docs/PYTHON_VERSION_MANAGEMENT.md`

106
.cursor/rules/code-style.md Normal file
View File

@@ -0,0 +1,106 @@
---
description: "Стиль кода, соглашения по именованию и форматированию"
alwaysApply: true
---
# Стиль кода и соглашения
## Именование
### Классы
- **PascalCase**: `UserRepository`, `AdminService`, `BaseDependencyFactory`
- Имена классов должны быть существительными
### Функции и методы
- **snake_case**: `get_user_info()`, `handle_message()`, `create_tables()`
- Имена функций должны быть глаголами или начинаться с глагола
### Переменные
- **snake_case**: `user_id`, `bot_db`, `settings`, `message_text`
- Константы в **UPPER_SNAKE_CASE**: `FSM_STATES`, `ERROR_MESSAGES`
### Модули и пакеты
- **snake_case**: `admin_handlers.py`, `user_repository.py`
- Имена модулей должны быть короткими и понятными
## Импорты
Структура импортов (в порядке приоритета):
```python
# 1. Standard library imports
import os
import asyncio
from typing import Optional, List
# 2. Third-party imports
from aiogram import Router, types
from aiogram.filters import Command
# 3. Local imports - модули проекта
from database.async_db import AsyncBotDB
from helper_bot.handlers.admin.services import AdminService
# 4. Local imports - utilities
from logs.custom_logger import logger
# 5. Local imports - metrics (если используются)
from helper_bot.utils.metrics import track_time, track_errors
```
## Type Hints
- Всегда используйте type hints для параметров функций и возвращаемых значений
- Используйте `Optional[T]` для значений, которые могут быть `None`
- Используйте `List[T]`, `Dict[K, V]` для коллекций
- Используйте `Annotated` для dependency injection в aiogram
Пример:
```python
async def get_user_info(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Получение информации о пользователе."""
...
```
## Документация
### Docstrings
- Используйте docstrings для всех классов и публичных методов
- Формат: краткое описание в одну строку или многострочный с подробностями
```python
async def add_user(self, user: User) -> None:
"""Добавление нового пользователя с защитой от дублирования."""
...
```
### Комментарии
- Комментарии на русском языке (как и весь код)
- Используйте комментарии для объяснения "почему", а не "что"
- Разделители секций: `# ============================================================================`
## Форматирование
- Используйте 4 пробела для отступов (не табы)
- Максимальная длина строки: 100-120 символов (гибко)
- Пустые строки между логическими блоками
- Пустая строка перед `return` в конце функции (если функция не короткая)
## Структура файлов handlers
```python
# 1. Импорты (по категориям)
# 2. Создание роутера
router = Router()
# 3. Регистрация middleware (если нужно)
router.message.middleware(SomeMiddleware())
# 4. Handlers с декораторами
@router.message(...)
@track_time("handler_name", "module_name")
@track_errors("module_name", "handler_name")
async def handler_function(...):
"""Описание handler."""
...
```

View File

@@ -0,0 +1,217 @@
---
description: "Паттерны работы с базой данных, репозитории и модели"
globs: ["database/**/*.py", "**/repositories/*.py"]
---
# Паттерны работы с базой данных
## Repository Pattern
Все операции с БД выполняются через репозитории. Каждый репозиторий:
- Наследуется от `DatabaseConnection` из `database/base.py`
- Работает с одной сущностью (User, Post, Blacklist, etc.)
- Содержит методы для CRUD операций
- Использует асинхронные методы `_execute_query()` и `_execute_query_with_result()`
### Структура репозитория
```python
from database.base import DatabaseConnection
from database.models import User
class UserRepository(DatabaseConnection):
"""Репозиторий для работы с пользователями."""
async def create_tables(self):
"""Создание таблицы пользователей."""
query = '''
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
...
)
'''
await self._execute_query(query)
self.logger.info("Таблица пользователей создана")
async def add_user(self, user: User) -> None:
"""Добавление нового пользователя."""
query = "INSERT OR IGNORE INTO our_users (...) VALUES (...)"
params = (...)
await self._execute_query(query, params)
self.logger.info(f"Пользователь добавлен: {user.user_id}")
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получение пользователя по ID."""
query = "SELECT * FROM our_users WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
# Преобразование row в модель User
...
```
## Модели данных
- Модели определены в `database/models.py`
- Используются dataclasses или простые классы
- Модели передаются между слоями (Repository → Service → Handler)
## Работа с БД
### AsyncBotDB
- Основной интерфейс для работы с БД
- Использует `RepositoryFactory` для доступа к репозиториям
- Методы делегируют вызовы соответствующим репозиториям
### DatabaseConnection
- Базовый класс для всех репозиториев
- Предоставляет методы:
- `_get_connection()` - получение соединения
- `_execute_query()` - выполнение запроса без результата
- `_execute_query_with_result()` - выполнение запроса с результатом
- Автоматически управляет соединениями (открытие/закрытие)
- Настраивает PRAGMA для оптимизации SQLite
### Важные правила
1. **Всегда используйте параметризованные запросы** для защиты от SQL injection:
```python
# ✅ Правильно
query = "SELECT * FROM users WHERE user_id = ?"
await self._execute_query_with_result(query, (user_id,))
# ❌ Неправильно
query = f"SELECT * FROM users WHERE user_id = {user_id}"
```
2. **Логируйте важные операции**:
```python
self.logger.info(f"Пользователь добавлен: {user_id}")
self.logger.error(f"Ошибка при добавлении пользователя: {e}")
```
3. **Используйте транзакции** для множественных операций (если нужно):
```python
async with await self._get_connection() as conn:
await conn.execute(...)
await conn.execute(...)
await conn.commit()
```
4. **Обрабатывайте None** при получении данных:
```python
rows = await self._execute_query_with_result(query, params)
if not rows:
return None
row = rows[0]
```
## RepositoryFactory
- Создает и кэширует экземпляры репозиториев
- Доступ через свойства: `factory.users`, `factory.posts`, etc.
- Используется в `AsyncBotDB` для доступа к репозиториям
## Миграции
### Обзор
Система миграций автоматически отслеживает и применяет изменения схемы БД. Миграции хранятся в `scripts/` и применяются автоматически при деплое.
### Создание миграции
1. **Создайте файл** в `scripts/` с понятным именем (например, `add_user_email_column.py`)
2. **Обязательные требования:**
- Функция `async def main(db_path: str)`
- Использует `aiosqlite` для работы с БД
- **Идемпотентна** - можно запускать несколько раз без ошибок
- Проверяет текущее состояние перед применением изменений
3. **Пример структуры:**
```python
#!/usr/bin/env python3
import argparse
import asyncio
import os
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
import aiosqlite
from logs.custom_logger import logger
DEFAULT_DB_PATH = "database/tg-bot-database.db"
async def main(db_path: str) -> None:
"""Основная функция миграции."""
db_path = os.path.abspath(db_path)
if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}")
return
async with aiosqlite.connect(db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
# Проверяем текущее состояние
cursor = await conn.execute("PRAGMA table_info(users)")
columns = await cursor.fetchall()
# Проверяем, нужно ли применять изменения
column_exists = any(col[1] == "email" for col in columns)
if not column_exists:
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
await conn.commit()
logger.info("Колонка email добавлена")
else:
logger.info("Колонка email уже существует")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Добавление колонки email")
parser.add_argument(
"--db",
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
help="Путь к БД",
)
args = parser.parse_args()
asyncio.run(main(args.db))
```
### Применение миграций
**Локально:**
```bash
python3 scripts/apply_migrations.py --dry-run # проверить
python3 scripts/apply_migrations.py # применить
```
**В продакшене:** Применяются автоматически при деплое через CI/CD (перед перезапуском контейнера).
### Важные правила
1. **Идемпотентность** - всегда проверяйте состояние перед изменением:
```python
# ✅ Правильно
cursor = await conn.execute("PRAGMA table_info(users)")
columns = await cursor.fetchall()
if not any(col[1] == "email" for col in columns):
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
# ❌ Неправильно - упадет при повторном запуске
await conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
```
2. **Порядок применения** - миграции применяются в алфавитном порядке по имени файла
3. **Исключения** - следующие скрипты не считаются миграциями:
- `apply_migrations.py`, `backfill_migrations.py`, `test_s3_connection.py`, `voice_cleanup.py`
### Регистрация существующих миграций
Если миграции уже применены, но не зарегистрированы:
```bash
python3 scripts/backfill_migrations.py # зарегистрировать все существующие
```

View File

@@ -0,0 +1,172 @@
---
description: "Работа с зависимостями, утилитами, метриками и внешними сервисами"
globs: ["helper_bot/utils/**/*.py", "helper_bot/config/**/*.py"]
---
# Зависимости и утилиты
## BaseDependencyFactory
Центральный класс для управления зависимостями проекта.
### Использование
```python
from helper_bot.utils.base_dependency_factory import get_global_instance
# Получение глобального экземпляра
bdf = get_global_instance()
# Доступ к зависимостям
db = bdf.get_db() # AsyncBotDB
settings = bdf.get_settings() # dict с настройками
s3_storage = bdf.get_s3_storage() # S3StorageService или None
```
### Структура settings
Настройки загружаются из `.env` и структурированы:
```python
settings = {
'Telegram': {
'bot_token': str,
'listen_bot_token': str,
'preview_link': bool,
'main_public': str,
'group_for_posts': int,
'important_logs': int,
...
},
'Settings': {
'logs': bool,
'test': bool
},
'Metrics': {
'host': str,
'port': int
},
'S3': {
'enabled': bool,
'endpoint_url': str,
'access_key': str,
'secret_key': str,
'bucket_name': str,
'region': str
}
}
```
## Метрики
### Декораторы метрик
Используйте декораторы из `helper_bot.utils.metrics`:
```python
from helper_bot.utils.metrics import track_time, track_errors, db_query_time
@track_time("method_name", "module_name")
@track_errors("module_name", "method_name")
async def some_method():
"""Метод с отслеживанием времени и ошибок."""
...
@db_query_time("method_name", "table_name", "operation")
async def db_method():
"""Метод БД с отслеживанием времени запросов."""
...
```
### Доступ к метрикам
```python
from helper_bot.utils.metrics import metrics
# Метрики доступны через Prometheus на порту из settings['Metrics']['port']
```
## Rate Limiting
### RateLimiter
Используется для ограничения частоты запросов:
```python
from helper_bot.utils.rate_limiter import RateLimiter
limiter = RateLimiter(...)
if await limiter.is_allowed(user_id):
# Разрешить действие
...
else:
# Отклонить действие
...
```
### RateLimitMiddleware
Автоматически применяет rate limiting ко всем запросам через middleware.
## S3 Storage
### S3StorageService
Используется для хранения медиафайлов:
```python
from helper_bot.utils.s3_storage import S3StorageService
# Получение через BaseDependencyFactory
s3_storage = bdf.get_s3_storage()
if s3_storage:
# Загрузка файла
url = await s3_storage.upload_file(file_path, object_key)
# Удаление файла
await s3_storage.delete_file(object_key)
```
### Проверка доступности
Всегда проверяйте, что S3 включен:
```python
s3_storage = bdf.get_s3_storage()
if s3_storage:
# Работа с S3
...
else:
# Fallback логика
...
```
## Утилиты
### helper_func.py
Содержит вспомогательные функции для работы с:
- Датами и временем
- Форматированием данных
- Валидацией
- Преобразованием данных
Используйте эти функции вместо дублирования логики.
## Конфигурация
### rate_limit_config.py
Конфигурация rate limiting находится в `helper_bot/config/rate_limit_config.py`.
Используйте конфигурацию вместо хардкода значений.
## Best Practices
1. **Всегда получайте зависимости через BaseDependencyFactory** - не создавайте экземпляры напрямую
2. **Используйте декораторы метрик** для всех важных методов
3. **Проверяйте доступность внешних сервисов** (S3) перед использованием
4. **Используйте утилиты** из `helper_func.py` вместо дублирования кода
5. **Читайте настройки из settings** вместо хардкода значений
6. **Логируйте важные операции** с внешними сервисами

View File

@@ -0,0 +1,410 @@
# Инструкция по деплою на продакшен
## Общая информация
**⚠️ ВАЖНО:** Креды для доступа к серверу (SSH хост, порт, пользователь) предоставляются пользователем в запросе и НЕ должны сохраняться в этой документации!
**Технологический стек:**
- Python: 3.11.9
- Docker: Alpine Linux
- База данных: SQLite
**Директория проекта на сервере:** `/home/prod/bots/telegram-helper-bot`
**Директория для команд инфраструктуры:** `/home/prod`
**База данных:** `/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db`
---
## Этап 1: Подготовка на локальной машине
### 1.1. Проверка состояния репозитория
```bash
git status
git log --oneline -10
git diff master...HEAD --stat
```
### 1.2. Пуш ветки в репозиторий
```bash
git push -u origin <branch-name>
```
### 1.3. Создание Release Notes
**ВАЖНО:** Release notes создаются ПОСЛЕ пуша, чтобы не попали в репозиторий!
1. Использовать шаблон из `.cursor/rules/release-notes-template.md`
2. Создать файл `RELEASE_NOTES_DEV-XX.md` в корне проекта
3. Собрать информацию о коммитах:
```bash
git log master..HEAD --pretty=format:"%h - %ai - %s" --reverse
```
4. Заполнить release notes по шаблону:
- Обзор (количество коммитов и основные направления)
- Ключевые изменения (каждый значимый коммит)
- Основные достижения (чекбоксы с эмодзи ✅)
- Временная шкала разработки
**Примечание:** Release notes используются для Pull Request, затем файл удаляется из рабочей директории.
### 1.4. Создание дополнительной документации (если требуется)
**ВАЖНО:** Документацию создавать ПОСЛЕ пуша, чтобы она не попала в репозиторий!
1. Создать или обновить инструкции по деплою
2. Обновить необходимую техническую документацию
3. Убедиться, что не добавляются файлы с секретами
---
## Этап 2: Деплой на сервер (тестовая ветка)
**ВАЖНО:** Креды для подключения к серверу должны быть предоставлены пользователем в запросе.
### 2.1. Подключение к серверу и остановка инфраструктуры
```bash
# Подключиться к серверу через SSH
# Перейти в директорию для команд инфраструктуры
cd /home/prod
make down
```
**Проверка:** Убедиться, что все контейнеры остановлены.
### 2.2. Создание бэкапа базы данных
```bash
cd /home/prod/bots/telegram-helper-bot/database
cp tg-bot-database.db tg-bot-database_$(date +%Y%m%d_%H%M%S).db
ls -lh tg-bot-database*.db
```
**ВАЖНО:** Всегда создавать бэкап с timestamp перед применением миграций!
### 2.3. Переключение на ветку и подтягивание изменений
```bash
cd /home/prod/bots/telegram-helper-bot
git fetch origin
git checkout <branch-name>
git pull origin <branch-name>
```
### 2.4. Установка зависимостей (если изменились)
#### Если виртуальное окружение уже существует:
```bash
cd /home/prod/bots/telegram-helper-bot
.venv/bin/pip install -r requirements.txt
```
#### Если виртуального окружения нет:
```bash
cd /home/prod/bots/telegram-helper-bot
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
```
**Примечание:** Виртуальное окружение `.venv` на сервере используется только для применения миграций и утилит. Бот работает в Docker-контейнере.
### 2.5. Применение миграций базы данных
```bash
cd /home/prod/bots/telegram-helper-bot
.venv/bin/python scripts/apply_migrations.py
```
**ВАЖНО:**
- Миграции применяются **ПЕРЕД** запуском контейнеров
- База должна быть "спящей" (контейнеры выключены)
- Проверить вывод скрипта на наличие ошибок
**Вывод при успехе:**
```
📋 Найдено новых миграций: X
Все миграции применены успешно (X шт.)
```
### 2.6. Запуск инфраструктуры
```bash
cd /home/prod
make up
```
### 2.7. Пересборка контейнера telegram-bot
Используем команду с гарантией применения изменений:
```bash
cd /home/prod
docker-compose up -d --build --force-recreate --no-deps telegram-bot
```
**Что делает команда:**
- `--build` - пересобирает образ
- `--force-recreate` - принудительно пересоздает контейнер
- `--no-deps` - не затрагивает зависимые сервисы
- Гарантирует доставку нового кода в контейнер без кэша
### 2.8. Проверка работоспособности
```bash
# Проверка статуса всех контейнеров
docker-compose ps
# Проверка логов telegram-bot
docker-compose logs telegram-bot --tail=50
# Или напрямую
docker logs bots_telegram_bot --tail=50
```
**Что проверять в логах:**
- ✅ База данных инициализирована
- ✅ Scoring Manager инициализирован (если используется)
- ✅ Metrics server запущен на порту 8080
- ✅ Нет критических ошибок
- ✅ Бот обрабатывает команды
**На этом этапе:** Ждать подтверждения от владельца о мердже ветки в master.
---
## Этап 3: Финальный деплой на master
**ВАЖНО:** Выполняется только после подтверждения мерджа ветки в master!
### 3.1. Остановка инфраструктуры
```bash
cd /home/prod
make down
```
### 3.2. Переключение на master и получение изменений
```bash
cd /home/prod/bots/telegram-helper-bot
git checkout master
git pull origin master
```
**Проверка:** Убедиться, что получен коммит с мерджем.
### 3.3. Запуск инфраструктуры
```bash
cd /home/prod
make up
```
### 3.4. Пересборка контейнера telegram-bot с кодом master
```bash
cd /home/prod
docker-compose up -d --build --force-recreate --no-deps telegram-bot
```
### 3.5. Финальная проверка
```bash
# Статус контейнеров
docker-compose ps
# Логи
docker logs bots_telegram_bot --tail=50
```
**Проверить:**
- ✅ Все контейнеры в состоянии `Up` и `healthy`
- ✅ Логи не содержат критических ошибок
- ✅ Бот отвечает на команды в Telegram
### 3.6. Завершение работы
- Выйти с сервера
- Забыть все креды доступа (не сохранять их)
---
## Troubleshooting
### Проблема: Миграции не применяются
**Решение:**
1. Проверить, что контейнеры остановлены
2. Проверить права доступа к файлу БД
3. Посмотреть логи скрипта apply_migrations.py
4. Восстановить БД из бэкапа при необходимости:
```bash
cd /home/prod/bots/telegram-helper-bot/database
cp tg-bot-database_YYYYMMDD_HHMMSS.db tg-bot-database.db
```
### Проблема: Контейнер не запускается
**Решение:**
1. Проверить логи контейнера:
```bash
docker logs bots_telegram_bot --tail=100
```
2. Проверить переменные окружения в `.env`
3. Проверить healthcheck:
```bash
docker inspect bots_telegram_bot | grep -A 20 Health
```
### Проблема: Код не обновился в контейнере
**Решение:**
Использовать принудительную пересборку без кэша:
```bash
docker-compose down telegram-bot
docker-compose build --no-cache telegram-bot
docker-compose up -d telegram-bot
```
### Проблема: Нет виртуального окружения
**Решение:**
Создать виртуальное окружение:
```bash
# Установить python3-venv (если нужно)
sudo apt install -y python3.11-venv
# Создать venv
cd /home/prod/bots/telegram-helper-bot
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
```
---
## Последовательность действий для агента
### Этап 1: Локальная подготовка
1. Проверить состояние репозитория (`git status`, `git log`)
2. Запушить ветку в репозиторий
3. Создать Release Notes по шаблону - **ПОСЛЕ пуша!**
4. Создать дополнительную документацию (если требуется) - **ПОСЛЕ пуша!**
5. Сообщить пользователю о готовности к деплою
### Этап 2: Деплой на dev-ветку
1. Подключиться к серверу (креды из запроса пользователя)
2. Остановить инфраструктуру (`make down` из `/home/prod`)
3. Создать бэкап БД с timestamp
4. Переключиться на dev-ветку и подтянуть изменения
5. Установить/обновить зависимости Python (если требуется)
6. Применить миграции БД (через `.venv/bin/python scripts/apply_migrations.py`)
7. Проверить успешность применения миграций
8. Запустить инфраструктуру (`make up`)
9. Пересобрать контейнер telegram-bot с `--build --force-recreate --no-deps`
10. Проверить статус контейнеров и логи
11. Сообщить пользователю о готовности и ждать подтверждения "ОК"
### Этап 3: Финальный деплой на master
**Выполняется только после получения "ОК" от пользователя (означает мердж в master)**
1. Остановить инфраструктуру
2. Переключиться на master и подтянуть изменения
3. Запустить инфраструктуру
4. Пересобрать контейнер telegram-bot с `--build --force-recreate --no-deps`
5. Проверить финальный статус и логи
6. Сообщить о успешном завершении деплоя
7. Завершить SSH-сессию и забыть все креды
---
## Важные команды
### Docker
```bash
# Просмотр всех контейнеров
docker-compose ps
# Логи конкретного сервиса
docker-compose logs <service-name> --tail=N
# Перезапуск конкретного сервиса
docker-compose restart <service-name>
# Остановка всей инфраструктуры
docker-compose down
# Запуск всей инфраструктуры
docker-compose up -d
# Пересборка без кэша
docker-compose build --no-cache <service-name>
# Принудительное пересоздание контейнера
docker-compose up -d --build --force-recreate --no-deps <service-name>
```
### Git
```bash
# Проверка состояния
git status
git branch
# Переключение веток
git checkout <branch-name>
# Получение изменений
git fetch origin
git pull origin <branch-name>
# История коммитов
git log --oneline -N
git log master..HEAD --pretty=format:"%h - %ai - %s" --reverse
```
### Работа с БД
```bash
# Создание бэкапа с timestamp
cp tg-bot-database.db tg-bot-database_$(date +%Y%m%d_%H%M%S).db
# Просмотр бэкапов
ls -lh tg-bot-database*.db
# Восстановление из бэкапа
cp tg-bot-database_BACKUP.db tg-bot-database.db
```
---
## Примечания
1. **Виртуальное окружение на сервере** - используется только для запуска утилит и миграций. Бот работает в Docker-контейнере.
2. **Бэкапы БД** - создаются перед каждым применением миграций с timestamp в имени файла.
3. **Пересборка контейнера** - всегда использовать `--force-recreate --no-deps` для гарантии применения изменений.
4. **Миграции** - применяются только при остановленных контейнерах к "спящей" базе данных.
5. **Release notes** - создаются ПОСЛЕ пуша, чтобы не попадали в репозиторий. Используются для Pull Request, затем удаляются из рабочей директории.
6. **Документация после пуша** - вся документация и инструкции создаются ПОСЛЕ пуша в репозиторий, чтобы не попадали в коммиты и удалённый репозиторий.
7. **Креды доступа** - креды сервера предоставляются пользователем в запросе и НЕ сохраняются в документации. После завершения деплоя - забыть все креды.
---
## Контакты
При возникновении проблем или вопросов обращаться к владельцу проекта.

View File

@@ -0,0 +1,207 @@
---
description: "Обработка ошибок, исключения и логирование"
alwaysApply: false
---
# Обработка ошибок и исключений
## Иерархия исключений
Каждый модуль должен иметь свой файл `exceptions.py` с иерархией исключений:
```python
# Базовое исключение модуля
class ModuleError(Exception):
"""Базовое исключение для модуля"""
pass
# Специализированные исключения
class UserNotFoundError(ModuleError):
"""Исключение при отсутствии пользователя"""
pass
class InvalidInputError(ModuleError):
"""Исключение при некорректном вводе данных"""
pass
```
## Обработка в Handlers
### Паттерн try-except
```python
@router.message(...)
async def handler(message: types.Message, state: FSMContext, **kwargs):
try:
# Основная логика
service = SomeService(bot_db, settings)
result = await service.do_something()
await message.answer("Успех")
except UserNotFoundError as e:
# Обработка специфичной ошибки
await message.answer(f"Пользователь не найден: {str(e)}")
logger.warning(f"Пользователь не найден: {e}")
except InvalidInputError as e:
# Обработка ошибки валидации
await message.answer(f"Некорректный ввод: {str(e)}")
logger.warning(f"Некорректный ввод: {e}")
except Exception as e:
# Обработка неожиданных ошибок
await handle_error(message, e, state)
logger.error(f"Неожиданная ошибка в handler: {e}", exc_info=True)
```
### Декоратор error_handler
Некоторые модули используют декоратор `@error_handler` для автоматической обработки:
```python
from .decorators import error_handler
@error_handler
@router.message(...)
async def handler(...):
# Код handler
# Ошибки автоматически логируются и отправляются в important_logs
...
```
## Обработка в Services
```python
class SomeService:
async def do_something(self):
try:
# Бизнес-логика
data = await self.bot_db.get_data()
if not data:
raise UserNotFoundError("Пользователь не найден")
return self._process(data)
except UserNotFoundError:
# Пробрасываем специфичные исключения дальше
raise
except Exception as e:
# Логируем и пробрасываем неожиданные ошибки
logger.error(f"Ошибка в сервисе: {e}", exc_info=True)
raise
```
## Логирование
### Использование logger
Всегда используйте `logs.custom_logger.logger`:
```python
from logs.custom_logger import logger
# Информационные сообщения
logger.info(f"Пользователь {user_id} выполнил действие")
# Предупреждения
logger.warning(f"Попытка доступа к несуществующему ресурсу: {resource_id}")
# Ошибки
logger.error(f"Ошибка при выполнении операции: {e}")
# Ошибки с traceback
logger.error(f"Критическая ошибка: {e}", exc_info=True)
```
### Уровни логирования
- `logger.debug()` - отладочная информация (детали выполнения, промежуточные значения, HTTP запросы(не используется в проекте))
- `logger.info()` - информационные сообщения о работе (успешные операции, важные события)
- `logger.warning()` - предупреждения о потенциальных проблемах (некритичные ошибки, таймауты)
- `logger.error()` - ошибки, требующие внимания (исключения, сбои)
- `logger.critical()` - критические ошибки
### Паттерн логирования в сервисах
При работе с внешними API и сервисами используйте следующий паттерн:
```python
from logs.custom_logger import logger
class ApiClient:
async def calculate_score(self, text: str) -> Score:
# Логируем начало операции (debug)
logger.debug(f"ApiClient: Отправка запроса на расчет скора (text_preview='{text[:50]}')")
try:
response = await self._client.post(url, json=data)
# Логируем статус ответа (debug)
logger.debug(f"ApiClient: Получен ответ (status={response.status_code})")
# Обрабатываем ответ
if response.status_code == 200:
result = response.json()
# Логируем успешный результат (info)
logger.info(f"ApiClient: Скор успешно получен (score={result['score']:.4f})")
return result
else:
# Логируем ошибку (error)
logger.error(f"ApiClient: Ошибка API (status={response.status_code})")
raise ApiError(f"Ошибка API: {response.status_code}")
except httpx.TimeoutException:
# Логируем таймаут (error)
logger.error(f"ApiClient: Таймаут запроса (>{timeout}с)")
raise
except httpx.RequestError as e:
# Логируем ошибку подключения (error)
logger.error(f"ApiClient: Ошибка подключения: {e}")
raise
except Exception as e:
# Логируем неожиданные ошибки (error)
logger.error(f"ApiClient: Неожиданная ошибка: {e}", exc_info=True)
raise
```
**Принципы:**
- `logger.debug()` - для деталей выполнения (URL, параметры запроса, статус ответа)
- `logger.info()` - для успешных операций с важными результатами
- `logger.warning()` - для некритичных проблем (валидация, таймауты в неважных операциях)
- `logger.error()` - для всех ошибок перед пробросом исключения
- Всегда логируйте ошибки перед `raise`
- Используйте `exc_info=True` для критических ошибок
## Метрики ошибок
Декоратор `@track_errors` автоматически отслеживает ошибки:
```python
@track_errors("module_name", "method_name")
async def some_method():
# Ошибки автоматически записываются в метрики
...
```
## Централизованная обработка
### В admin handlers
Используется функция `handle_admin_error()`:
```python
from helper_bot.handlers.admin.utils import handle_admin_error
try:
# Код
except Exception as e:
await handle_admin_error(message, e, state, "context_name")
```
### В других модулях
Создавайте аналогичные утилиты для централизованной обработки ошибок модуля.
## Best Practices
1. **Всегда логируйте ошибки** перед пробросом или обработкой
2. **Используйте специфичные исключения** вместо общих `Exception`
3. **Пробрасывайте исключения** из сервисов в handlers для обработки
4. **Не глотайте исключения** без логирования
5. **Используйте `exc_info=True`** для логирования traceback критических ошибок
6. **Обрабатывайте ошибки на правильном уровне**: бизнес-логика в сервисах, пользовательские сообщения в handlers

View File

@@ -0,0 +1,215 @@
---
description: "Паттерны для создания handlers, services и обработки событий aiogram"
globs: ["helper_bot/handlers/**/*.py"]
---
# Паттерны для Handlers
## Структура модуля handler
Каждый модуль handler (admin, callback, group, private, voice) должен содержать:
```
handlers/{module}/
├── __init__.py # Экспорт router
├── {module}_handlers.py # Основные handlers
├── services.py # Бизнес-логика
├── exceptions.py # Кастомные исключения
├── dependencies.py # Dependency injection (опционально)
├── constants.py # Константы (FSM states, messages)
└── utils.py # Вспомогательные функции (опционально)
```
## Создание Router
```python
from aiogram import Router
# Создаем роутер
router = Router()
# Регистрируем middleware (если нужно)
router.message.middleware(SomeMiddleware())
# Экспортируем в __init__.py
# from .{module}_handlers import router
```
## Структура Handler
```python
from aiogram import Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
router = Router()
@router.message(
ChatTypeFilter(chat_type=["private"]),
Command('command_name')
)
@track_time("handler_name", "module_name")
@track_errors("module_name", "handler_name")
async def handler_function(
message: types.Message,
state: FSMContext,
bot_db: AsyncBotDB, # Из DependenciesMiddleware
settings: dict, # Из DependenciesMiddleware
**kwargs
):
"""Описание handler."""
try:
# Логирование
logger.info(f"Обработка команды от пользователя: {message.from_user.id}")
# Получение данных из state (если нужно)
data = await state.get_data()
# Вызов сервиса для бизнес-логики
service = SomeService(bot_db, settings)
result = await service.do_something()
# Ответ пользователю
await message.answer("Результат")
# Обновление state (если нужно)
await state.set_state("NEW_STATE")
except SomeCustomException as e:
# Обработка кастомных исключений
await message.answer(f"Ошибка: {str(e)}")
logger.error(f"Ошибка в handler: {e}")
except Exception as e:
# Обработка общих ошибок
await handle_error(message, e, state)
logger.error(f"Неожиданная ошибка: {e}")
```
## Service Layer
Бизнес-логика выносится в сервисы:
```python
from logs.custom_logger import logger
from helper_bot.utils.metrics import track_time, track_errors
class SomeService:
"""Сервис для работы с ..."""
def __init__(self, bot_db: AsyncBotDB, settings: dict):
self.bot_db = bot_db
self.settings = settings
@track_time("method_name", "service_name")
@track_errors("service_name", "method_name")
async def do_something(self) -> SomeResult:
"""Описание метода."""
try:
# Работа с БД через bot_db
data = await self.bot_db.some_method()
# Бизнес-логика
result = self._process_data(data)
return result
except Exception as e:
logger.error(f"Ошибка в сервисе: {e}")
raise
```
## Dependency Injection
### Через DependenciesMiddleware (глобально)
- `bot_db: AsyncBotDB` - доступен во всех handlers
- `settings: dict` - настройки из .env
- `bot: Bot` - экземпляр бота
- `dp: Dispatcher` - dispatcher
### Через MagicData (локально)
```python
from aiogram.filters import MagicData
from typing import Annotated
from helper_bot.handlers.admin.dependencies import BotDB, Settings
@router.message(
Command('admin'),
MagicData(bot_db=BotDB, settings=Settings)
)
async def handler(
message: types.Message,
bot_db: Annotated[AsyncBotDB, BotDB],
settings: Annotated[dict, Settings]
):
...
```
### Через фабрики (для сервисов)
```python
# В dependencies.py
def get_some_service() -> SomeService:
"""Фабрика для SomeService"""
bdf = get_global_instance()
db = bdf.get_db()
settings = bdf.settings
return SomeService(db, settings)
# В handlers
@router.message(Command('cmd'))
async def handler(
message: types.Message,
service: Annotated[SomeService, get_some_service()]
):
...
```
## FSM (Finite State Machine)
```python
# Определение состояний в constants.py
FSM_STATES = {
"ADMIN": "ADMIN",
"AWAIT_INPUT": "AWAIT_INPUT",
...
}
# Установка состояния
await state.set_state(FSM_STATES["ADMIN"])
# Получение состояния
current_state = await state.get_state()
# Сохранение данных
await state.update_data(key=value)
# Получение данных
data = await state.get_data()
value = data.get("key")
# Очистка состояния
await state.clear()
```
## Фильтры
Используйте кастомные фильтры из `helper_bot.filters.main`:
- `ChatTypeFilter` - фильтр по типу чата (private, group, supergroup)
## Декораторы для метрик
Всегда добавляйте декораторы метрик к handlers и методам сервисов:
```python
@track_time("handler_name", "module_name") # Измерение времени выполнения
@track_errors("module_name", "handler_name") # Отслеживание ошибок
@db_query_time("method_name", "table_name", "operation") # Для БД операций
```
## Обработка ошибок
- Используйте кастомные исключения из `exceptions.py`
- Обрабатывайте исключения в handlers
- Логируйте все ошибки через `logger.error()`
- Используйте декоратор `@error_handler` для автоматической обработки (если есть)

View File

@@ -0,0 +1,109 @@
---
description: "Паттерны создания и использования middleware в aiogram"
globs: ["helper_bot/middlewares/**/*.py"]
---
# Паттерны Middleware
## Структура Middleware
Все middleware наследуются от `aiogram.BaseMiddleware`:
```python
from typing import Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
class CustomMiddleware(BaseMiddleware):
"""Описание middleware."""
async def __call__(
self,
handler: Callable,
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
# Логика до обработки handler
...
# Вызов следующего handler в цепочке
result = await handler(event, data)
# Логика после обработки handler
...
return result
```
## Порядок регистрации Middleware
В `main.py` middleware регистрируются в следующем порядке (важно!):
```python
# 1. DependenciesMiddleware - внедрение зависимостей
dp.update.outer_middleware(DependenciesMiddleware())
# 2. MetricsMiddleware - сбор метрик
dp.update.outer_middleware(MetricsMiddleware())
# 3. BlacklistMiddleware - проверка черного списка
dp.update.outer_middleware(BlacklistMiddleware())
# 4. RateLimitMiddleware - ограничение частоты запросов
dp.update.outer_middleware(RateLimitMiddleware())
```
## DependenciesMiddleware
Внедряет глобальные зависимости во все handlers:
```python
class DependenciesMiddleware(BaseMiddleware):
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
bdf = get_global_instance()
# Внедрение зависимостей
if 'bot_db' not in data:
data['bot_db'] = bdf.get_db()
if 'settings' not in data:
data['settings'] = bdf.settings
return await handler(event, data)
```
## Обработка ошибок в Middleware
```python
class CustomMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data):
try:
# Предобработка
...
result = await handler(event, data)
# Постобработка
...
return result
except Exception as e:
# Обработка ошибок
logger.error(f"Ошибка в middleware: {e}")
# Решаем: пробрасывать дальше или обработать
raise
```
## Регистрация на уровне Router
Middleware можно регистрировать на уровне конкретного router:
```python
router = Router()
router.message.middleware(SomeMiddleware()) # Только для message handlers
router.callback_query.middleware(SomeMiddleware()) # Только для callback handlers
```
## Best Practices
1. **Регистрируйте middleware в правильном порядке** - зависимости должны быть первыми
2. **Не изменяйте event** напрямую, используйте `data` для передачи информации
3. **Обрабатывайте ошибки** в middleware, но не глотайте их без логирования
4. **Используйте `outer_middleware`** для глобальной регистрации
5. **Используйте `router.middleware()`** для локальной регистрации на уровне модуля

View File

@@ -0,0 +1,8 @@
---
name: middleware-patterns
description: This is a new rule
---
# Overview
Insert overview text here. The agent will only see this should they choose to apply the rule.

View File

@@ -0,0 +1,75 @@
---
name: project-rule
description: Общее описание всех ролей и правил проекта
---
# Правила проекта Telegram Helper Bot
Этот файл объединяет все правила и паттерны разработки проекта. Для получения подробной информации по каждому разделу см. соответствующие файлы в `.cursor/rules/`.
## 📋 Список правил и шаблонов
### 1. Архитектура проекта
**Файл:** `.cursor/rules/architecture.md`
**Описание:** Архитектурные паттерны и структура проекта Telegram бота на aiogram 3.10.0
**Применение:** `alwaysApply: true` - применяется автоматически
### 2. Стиль кода
**Файл:** `.cursor/rules/code-style.md`
**Описание:** Стиль кода, соглашения по именованию и форматированию
**Применение:** `alwaysApply: true` - применяется автоматически
### 3. Паттерны работы с БД
**Файл:** `.cursor/rules/database-patterns.md`
**Описание:** Паттерны работы с базой данных, репозитории и модели
**Применение:** Применяется к файлам `database/**/*.py` и `**/repositories/*.py`
### 4. Зависимости и утилиты
**Файл:** `.cursor/rules/dependencies-and-utils.md`
**Описание:** Работа с зависимостями, утилитами, метриками и внешними сервисами
**Применение:** Применяется к файлам `helper_bot/utils/**/*.py` и `helper_bot/config/**/*.py`
### 5. Обработка ошибок
**Файл:** `.cursor/rules/error-handling.md`
**Описание:** Обработка ошибок, исключения и логирование
**Применение:** Опционально (не всегда применяется)
### 6. Паттерны Handlers
**Файл:** `.cursor/rules/handlers-patterns.md`
**Описание:** Паттерны для создания handlers, services и обработки событий aiogram
**Применение:** Применяется к файлам `helper_bot/handlers/**/*.py`
### 7. Паттерны Middleware
**Файл:** `.cursor/rules/middleware-patterns.md`
**Описание:** Паттерны создания и использования middleware в aiogram
**Применение:** Применяется к файлам `helper_bot/middlewares/**/*.py`
### 8. Паттерны тестирования
**Файл:** `.cursor/rules/testing.md`
**Описание:** Паттерны тестирования, структура тестов и использование pytest
**Применение:** Применяется к файлам `tests/**/*.py` и `test_*.py`
### 9. Шаблон Release Notes
**Файл:** `.cursor/rules/release-notes-template.md`
**Описание:** Инструкция по оформлению Release Notes
**Применение:** Используется при создании файлов Release Notes
## 🎯 Ключевые принципы проекта
1. **Архитектура:** Repository Pattern → Service Layer → Handlers
2. **Асинхронность:** Все операции с БД и API асинхронные
3. **Типизация:** Type hints везде, где возможно
4. **Логирование:** Всегда через `logs.custom_logger.logger`
5. **Метрики:** Декораторы `@track_time`, `@track_errors`, `@db_query_time`
6. **Версия Python:** Python 3.11.9 во всех окружениях
## 📚 Как использовать
При работе над проектом агент должен:
- Следовать архитектурным паттернам из `architecture.md`
- Применять стиль кода из `code-style.md`
- Использовать соответствующие паттерны в зависимости от контекста (handlers, middleware, database, testing)
- Обрабатывать ошибки согласно `error-handling.md`
- При создании Release Notes следовать `release-notes-template.md`
Все правила автоматически применяются в зависимости от контекста редактируемых файлов благодаря настройкам `alwaysApply` и `globs` в frontmatter каждого файла.

View File

@@ -0,0 +1,124 @@
# Инструкция по оформлению Release Notes
## Назначение
Этот документ описывает структуру и формат для создания файлов Release Notes (например, `docs/RELEASE_NOTES_DEV-XX.md`).
## Структура документа
### 1. Заголовок
```markdown
# Release Notes: [название-ветки]
```
### 2. Обзор
Краткий абзац (1-2 предложения), описывающий:
- Количество коммитов в ветке
- Основные направления изменений
**Формат:**
```markdown
## Обзор
Ветка [название] содержит [N] коммитов с ключевыми улучшениями: [краткое перечисление основных изменений].
```
### 3. Ключевые изменения
Основной раздел с пронумерованными подразделами для каждого значимого изменения.
**Структура каждого подраздела:**
```markdown
### [Номер]. [Название изменения]
**Коммит:** `[hash]`
**Что сделано:**
- [Краткое описание изменения 1]
- [Краткое описание изменения 2]
- [Краткое описание изменения 3]
```
**Правила:**
- Каждое изменение = отдельный подраздел
- Название должно быть кратким и понятным
- В разделе "Что сделано" используй маркированные списки
- НЕ перечисляй затронутые файлы
- НЕ указывай статистику строк кода
- Фокусируйся на сути изменений, а не на технических деталях
- Разделяй подразделы горизонтальной линией `---`
### 4. Основные достижения
Раздел с чекбоксами, подводящий итоги релиза.
**Формат:**
```markdown
## 🎯 Основные достижения
✅ [Достижение 1]
✅ [Достижение 2]
✅ [Достижение 3]
```
**Правила:**
- Используй эмодзи ✅ для каждого достижения
- Каждое достижение на отдельной строке
- Краткие формулировки (3-5 слов)
- Фокусируйся на ключевых фичах и улучшениях
### 5. Временная шкала разработки
Раздел с информацией о сроках разработки.
**Формат:**
```markdown
## 📅 Временная шкала разработки
**Последние изменения:** [дата]
**Основная разработка:** [период]
**Предыдущие улучшения:** [контекст предыдущих веток/изменений]
**Хронология коммитов:**
- `[hash]` - [дата и время] - [краткое описание]
- `[hash]` - [дата и время] - [краткое описание]
```
**Правила:**
- Используй реальные даты из коммитов
- Формат даты: "DD месяц YYYY" (например, "25 января 2026")
- Для времени используй формат "HH:MM"
- Хронология должна быть в хронологическом порядке (от старых к новым)
## Стиль написания
### Общие правила:
- **Краткость**: Фокусируйся на сути, избегай избыточных деталей
- **Ясность**: Используй простые и понятные формулировки
- **Структурированность**: Информация должна быть легко читаемой и сканируемой
- **Без технических деталей**: Не перечисляй файлы, классы, методы (только если это ключевая фича)
- **Без статистики**: Не указывай количество строк кода, файлов и т.д.
### Язык:
- Используй прошедшее время для описания изменений ("Добавлена", "Реализована", "Обновлена")
- Избегай технического жаргона, если это не необходимо
- Используй активный залог
### Эмодзи:
- 🔥 для раздела "Ключевые изменения"
- 🎯 для раздела "Основные достижения"
- 📅 для раздела "Временная шкала разработки"
- ✅ для чекбоксов достижений
## Пример использования
При создании Release Notes для новой ветки:
1. Получи список коммитов: `git log [base-branch]..[target-branch] --oneline`
2. Для каждого значимого коммита создай подраздел в "Ключевые изменения"
3. Собери основные достижения в раздел "Основные достижения"
4. Добавь временную шкалу с реальными датами коммитов
5. Проверь, что документ следует структуре и стилю
## Важные замечания
- **НЕ включай** информацию о коммитах, которые уже были в базовой ветке (master/main)
- **НЕ перечисляй** все файлы, которые были изменены
- **НЕ указывай** статистику строк кода
- **Фокусируйся** на функциональных изменениях, а не на технических деталях реализации
- Используй **реальные даты** из коммитов, а не предполагаемые

197
.cursor/rules/testing.md Normal file
View File

@@ -0,0 +1,197 @@
---
description: "Паттерны тестирования, структура тестов и использование pytest"
globs: ["tests/**/*.py", "test_*.py"]
---
# Паттерны тестирования
## Структура тестов
Тесты находятся в директории `tests/` и используют pytest:
```
tests/
├── conftest.py # Общие фикстуры
├── conftest_*.py # Специализированные фикстуры
├── mocks.py # Моки и заглушки
└── test_*.py # Тестовые файлы
```
## Конфигурация pytest
Настройки в `pyproject.toml`:
- `asyncio-mode=auto` - автоматический режим для async тестов
- Маркеры: `asyncio`, `slow`, `integration`, `unit`
- Фильтрация предупреждений
## Структура теста
```python
import pytest
from database.async_db import AsyncBotDB
from database.repositories.user_repository import UserRepository
@pytest.mark.asyncio
async def test_user_repository_add_user(db_path):
"""Тест добавления пользователя."""
# Arrange
repo = UserRepository(db_path)
user = User(user_id=123, full_name="Test User")
# Act
await repo.add_user(user)
# Assert
result = await repo.get_user_by_id(123)
assert result is not None
assert result.full_name == "Test User"
```
## Фикстуры
### Общие фикстуры (conftest.py)
```python
import pytest
import tempfile
import os
@pytest.fixture
def db_path():
"""Создает временный файл БД для тестов."""
fd, path = tempfile.mkstemp(suffix='.db')
os.close(fd)
yield path
os.unlink(path)
@pytest.fixture
async def async_db(db_path):
"""Создает AsyncBotDB для тестов."""
db = AsyncBotDB(db_path)
await db.create_tables()
yield db
```
### Использование фикстур
```python
@pytest.mark.asyncio
async def test_something(async_db):
# async_db уже инициализирован
result = await async_db.some_method()
assert result is not None
```
## Моки
Используйте `mocks.py` для общих моков:
```python
from unittest.mock import AsyncMock, MagicMock
def mock_bot():
"""Создает мок бота."""
bot = MagicMock()
bot.send_message = AsyncMock()
return bot
```
## Маркеры
Используйте маркеры для категоризации тестов:
```python
@pytest.mark.unit
@pytest.mark.asyncio
async def test_unit_test():
"""Быстрый unit тест."""
...
@pytest.mark.integration
@pytest.mark.asyncio
async def test_integration_test():
"""Медленный integration тест."""
...
@pytest.mark.slow
@pytest.mark.asyncio
async def test_slow_test():
"""Медленный тест."""
...
```
Запуск с фильтрацией:
```bash
pytest -m "not slow" # Пропустить медленные тесты
pytest -m unit # Только unit тесты
```
## Тестирование Handlers
```python
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_handler(mock_bot, mock_message):
"""Тест handler."""
# Arrange
dp = Dispatcher(storage=MemoryStorage())
# Регистрация handler
...
# Act
await dp.feed_update(mock_update)
# Assert
mock_bot.send_message.assert_called_once()
```
## Тестирование Services
```python
@pytest.mark.asyncio
async def test_service_method(async_db):
"""Тест метода сервиса."""
service = SomeService(async_db, {})
result = await service.do_something()
assert result is not None
```
## Тестирование Repositories
```python
@pytest.mark.asyncio
async def test_repository_crud(db_path):
"""Тест CRUD операций репозитория."""
repo = SomeRepository(db_path)
await repo.create_tables()
# Create
entity = SomeEntity(...)
await repo.add(entity)
# Read
result = await repo.get_by_id(entity.id)
assert result is not None
# Update
entity.field = "new_value"
await repo.update(entity)
# Delete
await repo.delete(entity.id)
result = await repo.get_by_id(entity.id)
assert result is None
```
## Best Practices
1. **Используйте фикстуры** для переиспользования setup/teardown
2. **Изолируйте тесты** - каждый тест должен быть независимым
3. **Используйте временные БД** для тестов репозиториев
4. **Мокируйте внешние зависимости** (API, файловая система)
5. **Пишите понятные имена тестов** - они должны описывать что тестируется
6. **Используйте Arrange-Act-Assert** паттерн
7. **Тестируйте граничные случаи** и ошибки

View File

@@ -1,97 +1,29 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# .dockerignore
.*
!.gitignore
# Virtual environments
# Исключаем тяжелые папки
voice_users/
logs/
.venv/
__pycache__
*.pyc
*.pyo
*.pyd
# Исключаем файлы БД (они создаются при запуске)
database/*.db
database/*.db-*
# Служебные файлы
Dockerfile
docker-compose*
README.md
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git/
.gitignore
# Logs
logs/*.log
# Database
*.db
*.db-shm
*.db-wal
# Tests
test_*.py
.pytest_cache/
# Documentation
*.md
tests/
test/
docs/
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Development files
Makefile
start_docker.sh
*.sh
# Stickers and media
Stick/
# Temporary files
*.tmp
*.temp
.cache/
# Backup files
*.bak
*.backup
# Environment files
.env*
!.env.example
# Monitoring configs (will be mounted)
prometheus.yml
grafana/
.idea/
.vscode/

95
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: CI pipeline
on:
push:
branches: [ 'dev-*', 'feature-*', 'fix-*' ]
pull_request:
branches: [ 'dev-*', 'feature-*', 'fix-*', 'main' ]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
name: Test & Code Quality
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Code style check (isort + Black, one order — no conflict)
run: |
echo "🔍 Applying isort then black (pyproject.toml: isort profile=black)..."
python -m isort .
python -m black .
echo "🔍 Checking that repo is already formatted (no diff after isort+black)..."
git diff --exit-code || (
echo "❌ Code style drift. From THIS repo root (telegram-helper-bot) run:"
echo " python -m isort . && python -m black . && git add -A && git commit -m 'style: isort + black'"
exit 1
)
- name: Linting (flake8) - Critical errors
run: |
echo "🔍 Running flake8 linter (critical errors only)..."
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics || true
- name: Linting (flake8) - Warnings
run: |
echo "🔍 Running flake8 linter (warnings)..."
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics || true
continue-on-error: true
- name: Run tests
run: |
echo "🧪 Running tests..."
python -m pytest tests/ -v --tb=short
- name: Send test success notification
if: success()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
✅ CI Tests Passed
📦 Repository: telegram-helper-bot
🌿 Branch: ${{ github.ref_name }}
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
✅ All tests passed! Code quality checks completed successfully.
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true
- name: Send test failure notification
if: failure()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
❌ CI Tests Failed
📦 Repository: telegram-helper-bot
🌿 Branch: ${{ github.ref_name }}
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true

382
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,382 @@
name: Deploy to Production
on:
push:
branches: [ main ]
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: true
type: choice
options:
- deploy
- rollback
rollback_commit:
description: 'Commit hash to rollback to (optional, uses last successful if empty)'
required: false
type: string
dry_run:
description: 'Dry run (deploy only — no SSH, just show what would run)'
required: false
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Production
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
env:
DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }}
concurrency:
group: production-deploy-telegram-helper-bot
cancel-in-progress: false
environment:
name: production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Dry run (simulate deploy steps)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: |
echo "🔍 DRY RUN — no SSH, no changes on server"
echo "Would run on server:"
echo " 1. cd /home/prod/bots/telegram-helper-bot"
echo " 2. CURRENT_COMMIT=\$(git rev-parse HEAD); write to .deploy_history_telegram_helper_bot.txt"
echo " 3. git fetch origin main && git reset --hard origin/main"
echo " 4. python3 scripts/apply_migrations.py --db ... (if DB exists)"
echo " 5. docker-compose -f /home/prod/docker-compose.yml config (validate)"
echo " 6. docker-compose stop telegram-bot; build --pull telegram-bot; up -d telegram-bot"
echo " 7. sleep 10; check container bots_telegram_bot"
echo ""
echo "Secrets/vars required: SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY, SSH_PORT, TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_BOT_TOKEN"
if [ -f docker-compose.yml ]; then
echo "✅ docker-compose.yml present in repo (validation would run on server from /home/prod)"
fi
- name: Deploy to server
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
set -e
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
echo "🚀 Starting deployment to production..."
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
cd /home/prod/bots/telegram-helper-bot
# Сохраняем информацию о коммите (до pull) — из репо telegram-helper-bot
CURRENT_COMMIT=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "📝 Current commit: $CURRENT_COMMIT"
echo "📝 Commit message: $COMMIT_MESSAGE"
echo "📝 Author: $COMMIT_AUTHOR"
# Записываем в историю деплоев
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
echo "${TIMESTAMP}|${CURRENT_COMMIT}|${COMMIT_MESSAGE}|${COMMIT_AUTHOR}|deploying" >> "$HISTORY_FILE"
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
# Обновляем код
echo "📥 Pulling latest changes from main..."
git fetch origin main
git reset --hard origin/main
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
NEW_COMMIT=$(git rev-parse HEAD)
echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT"
# Применяем миграции БД перед перезапуском контейнера
echo "🔄 Applying database migrations..."
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
if [ -f "$DB_PATH" ]; then
cd /home/prod/bots/telegram-helper-bot
python3 scripts/apply_migrations.py --db "$DB_PATH" || {
echo "❌ Ошибка при применении миграций!"
exit 1
}
echo "✅ Миграции применены успешно"
else
echo "⚠️ База данных не найдена, пропускаем миграции (будет создана при первом запуске)"
fi
# Валидация docker-compose
echo "🔍 Validating docker-compose configuration..."
cd /home/prod
docker-compose config > /dev/null || exit 1
echo "✅ docker-compose.yml is valid"
# Проверка дискового пространства
MIN_FREE_GB=5
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
docker system prune -f --volumes || true
fi
# Пересобираем и перезапускаем контейнер бота
echo "🔨 Rebuilding and restarting telegram-bot container..."
cd /home/prod
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
docker-compose stop telegram-bot || true
docker-compose build --pull telegram-bot
docker-compose up -d telegram-bot
echo "✅ Telegram bot container rebuilt and started"
# Ждем немного и проверяем healthcheck
echo "⏳ Waiting for container to start..."
sleep 10
if docker ps | grep -q bots_telegram_bot; then
echo "✅ Container is running"
else
echo "❌ Container failed to start!"
docker logs bots_telegram_bot --tail 50 || true
exit 1
fi
- name: Update deploy history
if: always() && env.DRY_RUN != 'true'
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
if [ -f "$HISTORY_FILE" ]; then
DEPLOY_STATUS="failed"
if [ "${{ job.status }}" = "success" ]; then
DEPLOY_STATUS="success"
fi
sed -i '$s/|deploying$/|'"$DEPLOY_STATUS"'/' "$HISTORY_FILE"
echo "✅ Deploy history updated: $DEPLOY_STATUS"
fi
- name: Send deployment notification
if: always() && env.DRY_RUN != 'true'
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
📦 Repository: telegram-helper-bot
🌿 Branch: main
📝 Commit: ${{ github.sha }}
👤 Author: ${{ github.actor }}
${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }}
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true
- name: Get PR body from merged PR
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
# Находим последний мерженный PR для main ветки по merge commit SHA
COMMIT_SHA="${{ github.sha }}"
PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1)
# Если не нашли по merge commit, ищем последний мерженный PR
if [ -z "$PR_NUMBER" ]; then
echo "⚠️ PR not found by merge commit, trying to get latest merged PR..."
PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number')
fi
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
echo "✅ Found PR #$PR_NUMBER"
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq '.body // ""')
if [ -n "$PR_BODY" ] && [ "$PR_BODY" != "null" ]; then
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "✅ PR body extracted successfully"
else
echo "⚠️ PR body is empty"
fi
else
echo "⚠️ No merged PR found for this commit"
fi
continue-on-error: true
- name: Send PR body to important logs
if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true' && env.PR_BODY != ''
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.IMPORTANT_LOGS_CHAT }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
📋 Pull Request Description (PR #${{ env.PR_NUMBER }}):
${{ env.PR_BODY }}
🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
📝 Commit: ${{ github.sha }}
continue-on-error: true
rollback:
runs-on: ubuntu-latest
name: Rollback to Previous Version
if: |
github.event_name == 'workflow_dispatch' &&
github.event.inputs.action == 'rollback'
environment:
name: production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Rollback on server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
username: ${{ vars.SERVER_USER || secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ vars.SSH_PORT || secrets.SSH_PORT || 22 }}
script: |
set -e
export TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
export TELEGRAM_TEST_BOT_TOKEN="${{ secrets.TELEGRAM_TEST_BOT_TOKEN }}"
echo "🔄 Starting rollback..."
cd /home/prod
# Определяем коммит для отката
ROLLBACK_COMMIT="${{ github.event.inputs.rollback_commit }}"
HISTORY_FILE="/home/prod/.deploy_history_telegram_helper_bot.txt"
if [ -z "$ROLLBACK_COMMIT" ]; then
echo "📝 No commit specified, finding last successful deploy..."
if [ -f "$HISTORY_FILE" ]; then
ROLLBACK_COMMIT=$(grep "|success$" "$HISTORY_FILE" | tail -1 | cut -d'|' -f2 || echo "")
fi
if [ -z "$ROLLBACK_COMMIT" ]; then
echo "❌ No successful deploy found in history!"
echo "💡 Please specify commit hash manually or check deploy history"
exit 1
fi
fi
echo "📝 Rolling back to commit: $ROLLBACK_COMMIT"
# Проверяем, что коммит существует
cd /home/prod/bots/telegram-helper-bot
if ! git cat-file -e "$ROLLBACK_COMMIT" 2>/dev/null; then
echo "❌ Commit $ROLLBACK_COMMIT not found!"
exit 1
fi
# Сохраняем текущий коммит
CURRENT_COMMIT=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" "$ROLLBACK_COMMIT" || echo "Rollback")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
echo "📝 Current commit: $CURRENT_COMMIT"
echo "📝 Target commit: $ROLLBACK_COMMIT"
echo "📝 Commit message: $COMMIT_MESSAGE"
# Исправляем права перед откатом
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
# Откатываем код
echo "🔄 Rolling back code..."
git fetch origin main
git reset --hard "$ROLLBACK_COMMIT"
# Исправляем права после отката
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
echo "✅ Code rolled back: $CURRENT_COMMIT → $ROLLBACK_COMMIT"
# Валидация docker-compose
echo "🔍 Validating docker-compose configuration..."
cd /home/prod
docker-compose config > /dev/null || exit 1
echo "✅ docker-compose.yml is valid"
# Проверка дискового пространства
MIN_FREE_GB=5
AVAILABLE_SPACE=$(df -BG /home/prod 2>/dev/null | tail -1 | awk '{print $4}' | sed 's/G//' || echo "0")
echo "💾 Available disk space: ${AVAILABLE_SPACE}GB"
if [ "$AVAILABLE_SPACE" -lt "$MIN_FREE_GB" ]; then
echo "⚠️ Insufficient disk space! Cleaning up Docker resources..."
docker system prune -f --volumes || true
fi
# Пересобираем и перезапускаем контейнер
echo "🔨 Rebuilding and restarting telegram-bot container..."
cd /home/prod
export TELEGRAM_BOT_TOKEN TELEGRAM_TEST_BOT_TOKEN
docker-compose stop telegram-bot || true
docker-compose build --pull telegram-bot
docker-compose up -d telegram-bot
echo "✅ Telegram bot container rebuilt and started"
# Записываем в историю
echo "${TIMESTAMP}|${ROLLBACK_COMMIT}|Rollback to: ${COMMIT_MESSAGE}|github-actions|rolled_back" >> "$HISTORY_FILE"
HISTORY_SIZE="${DEPLOY_HISTORY_SIZE:-10}"
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
echo "✅ Rollback completed successfully"
- name: Send rollback notification
if: always()
uses: appleboy/telegram-action@v1.0.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
📦 Repository: telegram-helper-bot
🌿 Branch: main
📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }}
👤 Triggered by: ${{ github.actor }}
${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }}
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
continue-on-error: true

10
.gitignore vendored
View File

@@ -34,6 +34,9 @@ database/test.db
test.db
*.db
# Случайно созданный файл при использовании SQLite :memory: не по назначению
:memory:
# IDE and editor files
.vscode/
.idea/
@@ -89,3 +92,10 @@ env/
ENV/
env.bak/
venv.bak/
# Other files
voice_users/
files/
# ML models and vectors cache
data/

View File

@@ -1 +1 @@
3.9.6
3.11.9

View File

@@ -1 +0,0 @@

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
###########################################
# Этап 1: Сборщик (Builder)
###########################################
FROM python:3.11.9-alpine as builder
# Устанавливаем инструменты для компиляции (если нужны для некоторых пакетов)
RUN apk add --no-cache \
gcc \
musl-dev \
libffi-dev \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY requirements.txt .
# Устанавливаем зависимости
RUN pip install --no-cache-dir --target /install -r requirements.txt
###########################################
# Этап 2: Финальный образ (Runtime)
###########################################
FROM python:3.11.9-alpine as runtime
# Создаем пользователя
RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy
WORKDIR /app
# Копируем зависимости
COPY --from=builder --chown=deploy:deploy /install /usr/local/lib/python3.11/site-packages
# Создаем структуру папок
RUN mkdir -p database logs voice_users && \
chown -R deploy:deploy /app
# Копируем исходный код
COPY --chown=deploy:deploy . .
USER deploy
# Healthcheck
HEALTHCHECK --interval=30s --timeout=15s --start-period=10s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=5)" || exit 1
EXPOSE 8080
CMD ["python", "-u", "run_helper.py"]

View File

@@ -1,70 +0,0 @@
# Multi-stage build for production
FROM python:3.9-slim as builder
# Install build dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy and install requirements
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.9-slim
# Set security options
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install runtime dependencies only
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
curl \
sqlite3 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create non-root user with fixed UID
RUN groupadd -g 1001 deploy && useradd -u 1001 -g deploy deploy
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN chown -R 1001:1001 /opt/venv
# Create app directory and set permissions
WORKDIR /app
RUN mkdir -p /app/database /app/logs && \
chown -R 1001:1001 /app
# Copy application code
COPY --chown=1001:1001 . .
# Initialize SQLite database with schema
RUN sqlite3 /app/database/tg-bot-database.db < /app/database/schema.sql && \
chown 1001:1001 /app/database/tg-bot-database.db && \
chmod 644 /app/database/tg-bot-database.db
# Switch to non-root user
USER deploy
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Expose metrics port
EXPOSE 8000
# Graceful shutdown
STOPSIGNAL SIGTERM
# Run application
CMD ["python", "run_helper.py"]

121
Makefile
View File

@@ -1,121 +0,0 @@
.PHONY: help build up down logs clean restart status deploy migrate backup
help: ## Показать справку
@echo "🐍 Telegram Bot - Доступные команды (Production Ready):"
@echo ""
@echo "🔧 Основные команды:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "📊 Мониторинг:"
@echo " Prometheus: http://localhost:9090"
@echo " Grafana: http://localhost:3000 (admin/admin)"
@echo " Bot Health: http://localhost:8000/health"
build: ## Собрать все контейнеры
docker-compose build
up: ## Запустить все сервисы
docker-compose up -d
down: ## Остановить все сервисы
docker-compose down
logs: ## Показать логи всех сервисов
docker-compose logs -f
logs-bot: ## Показать логи бота
docker-compose logs -f telegram-bot
logs-prometheus: ## Показать логи Prometheus
docker-compose logs -f prometheus
logs-grafana: ## Показать логи Grafana
docker-compose logs -f grafana
restart: ## Перезапустить все сервисы
docker-compose down
docker-compose up -d
restart-bot: ## Перезапустить только бота
docker-compose restart telegram-bot
restart-prometheus: ## Перезапустить только Prometheus
docker-compose restart prometheus
restart-grafana: ## Перезапустить только Grafana
docker-compose restart grafana
status: ## Показать статус контейнеров
docker-compose ps
health: ## Проверить здоровье сервисов
@echo "🏥 Checking service health..."
@curl -f http://localhost:8000/health || echo "❌ Bot health check failed"
@curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus health check failed"
@curl -f http://localhost:3000/api/health || echo "❌ Grafana health check failed"
check-python: ## Проверить версию Python в контейнере
@echo "🐍 Проверяю версию Python в контейнере..."
@docker exec telegram-bot python --version || echo "Контейнер не запущен"
deploy: ## Полный деплой на продакшен
@echo "🚀 Starting production deployment..."
@chmod +x scripts/deploy.sh
@./scripts/deploy.sh
migrate: ## Миграция с systemctl + cron на Docker
@echo "🔄 Starting migration from systemctl to Docker..."
@chmod +x scripts/migrate_from_systemctl.sh
@sudo ./scripts/migrate_from_systemctl.sh
backup: ## Создать backup данных
@echo "💾 Creating backup..."
@mkdir -p backups
@tar -czf "backups/backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
@echo "✅ Backup created in backups/"
restore: ## Восстановить из backup (указать файл: make restore FILE=backup.tar.gz)
@echo "🔄 Restoring from backup..."
@if [ -z "$(FILE)" ]; then echo "❌ Please specify backup file: make restore FILE=backup.tar.gz"; exit 1; fi
@tar -xzf "backups/$(FILE)" -C .
@echo "✅ Backup restored"
update: ## Обновить бота (pull latest code and redeploy)
@echo "📥 Pulling latest changes..."
@git pull origin main
@echo "🔨 Rebuilding and restarting..."
@make restart
clean: ## Очистить все контейнеры и образы
docker-compose down -v --rmi all
docker system prune -f
security-scan: ## Сканировать образы на уязвимости
@echo "🔍 Scanning Docker images for vulnerabilities..."
@docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v $(PWD):/workspace \
--workdir /workspace \
anchore/grype:latest \
telegram-helper-bot_telegram-bot:latest || echo "⚠️ Grype not available, skipping scan"
monitoring: ## Открыть мониторинг в браузере
@echo "📊 Opening monitoring dashboards..."
@open http://localhost:3000 || xdg-open http://localhost:3000 || echo "Please open manually: http://localhost:3000"
start: build up ## Собрать и запустить все сервисы
@echo "🐍 Telegram Bot запущен!"
@echo "📊 Prometheus: http://localhost:9090"
@echo "📈 Grafana: http://localhost:3000 (admin/admin)"
@echo "🤖 Bot Health: http://localhost:8000/health"
@echo "📝 Логи: make logs"
stop: down ## Остановить все сервисы
@echo "🛑 Все сервисы остановлены"
test: ## Запустить все тесты
@echo "🧪 Запускаю все тесты..."
@docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest"
test-coverage: ## Запустить все тесты с покрытием
@echo "🧪 Запускаю все тесты с покрытием..."
@docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=term-missing"

View File

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

45
database/__init__.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

118
database/base.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для диагностики и исправления проблем с базой данных Telegram бота.
"""
import os
import sys
import sqlite3
from pathlib import Path
def check_database_file(db_path):
"""Проверяет состояние файла базы данных."""
print(f"Проверка файла: {db_path}")
if not os.path.exists(db_path):
print(f"❌ Файл базы данных не найден: {db_path}")
return False
# Проверяем права доступа
if not os.access(db_path, os.R_OK | os.W_OK):
print(f"❌ Нет прав доступа к файлу: {db_path}")
return False
# Проверяем размер файла
file_size = os.path.getsize(db_path)
print(f"✅ Размер файла: {file_size} байт")
return True
def check_wal_files(db_path):
"""Проверяет WAL файлы."""
db_dir = os.path.dirname(db_path)
db_name = os.path.basename(db_path)
base_name = os.path.splitext(db_name)[0]
wal_file = os.path.join(db_dir, f"{base_name}.db-wal")
shm_file = os.path.join(db_dir, f"{base_name}.db-shm")
print(f"\nПроверка WAL файлов:")
if os.path.exists(wal_file):
wal_size = os.path.getsize(wal_file)
print(f"✅ WAL файл найден: {wal_file} ({wal_size} байт)")
else:
print(f" WAL файл не найден: {wal_file}")
if os.path.exists(shm_file):
shm_size = os.path.getsize(shm_file)
print(f"✅ SHM файл найден: {shm_file} ({shm_size} байт)")
else:
print(f" SHM файл не найден: {shm_file}")
return wal_file, shm_file
def test_database_connection(db_path):
"""Тестирует подключение к базе данных."""
print(f"\nТестирование подключения к базе данных...")
try:
conn = sqlite3.connect(db_path, timeout=10.0)
cursor = conn.cursor()
# Проверяем версию SQLite
cursor.execute("SELECT sqlite_version()")
version = cursor.fetchone()[0]
print(f"✅ SQLite версия: {version}")
# Проверяем таблицы
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print(f"✅ Найдено таблиц: {len(tables)}")
# Проверяем целостность
cursor.execute("PRAGMA integrity_check")
integrity = cursor.fetchone()[0]
if integrity == "ok":
print("✅ Целостность базы данных: OK")
else:
print(f"⚠️ Проблемы с целостностью: {integrity}")
conn.close()
return True
except sqlite3.Error as e:
print(f"❌ Ошибка SQLite: {e}")
return False
except Exception as e:
print(f"❌ Неожиданная ошибка: {e}")
return False
def cleanup_wal_files(db_path):
"""Очищает WAL файлы."""
print(f"\nОчистка WAL файлов...")
try:
conn = sqlite3.connect(db_path, timeout=10.0)
cursor = conn.cursor()
# Переключаем на DELETE режим для очистки WAL
cursor.execute("PRAGMA journal_mode=DELETE")
cursor.execute("PRAGMA journal_mode=WAL")
# Принудительно создаем checkpoint
cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.close()
print("✅ WAL файлы очищены")
return True
except Exception as e:
print(f"❌ Ошибка при очистке WAL файлов: {e}")
return False
def main():
"""Основная функция."""
print("🔧 Диагностика базы данных Telegram бота")
print("=" * 50)
# Определяем путь к базе данных
current_dir = os.getcwd()
db_path = os.path.join(current_dir, 'database', 'tg-bot-database.db')
print(f"Текущая директория: {current_dir}")
print(f"Путь к базе данных: {db_path}")
# Проверяем файл базы данных
if not check_database_file(db_path):
print("\n❌ Файл базы данных недоступен. Проверьте права доступа и существование файла.")
return
# Проверяем WAL файлы
wal_file, shm_file = check_wal_files(db_path)
# Тестируем подключение
if not test_database_connection(db_path):
print("\nНе удалось подключиться к базе данных.")
return
# Очищаем WAL файлы
if cleanup_wal_files(db_path):
print("\n✅ База данных проверена и исправлена.")
else:
print("\n⚠️ База данных проверена, но не удалось очистить WAL файлы.")
print("\n📋 Рекомендации:")
print("1. Убедитесь, что у процесса есть права на запись в директорию database/")
print("2. Проверьте свободное место на диске")
print("3. Если проблемы продолжаются, попробуйте перезапустить бота")
print("4. В крайнем случае, создайте резервную копию и пересоздайте базу данных")
if __name__ == "__main__":
main()

130
database/models.py Normal file
View File

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

View File

@@ -0,0 +1,36 @@
"""
Пакет репозиториев для работы с базой данных.
Содержит репозитории для разных сущностей:
- user_repository: работа с пользователями
- blacklist_repository: работа с черным списком
- blacklist_history_repository: работа с историей банов/разбанов
- message_repository: работа с сообщениями
- post_repository: работа с постами
- admin_repository: работа с администраторами
- audio_repository: работа с аудио
- migration_repository: работа с миграциями БД
- bot_settings_repository: работа с настройками бота
"""
from .admin_repository import AdminRepository
from .audio_repository import AudioRepository
from .blacklist_history_repository import BlacklistHistoryRepository
from .blacklist_repository import BlacklistRepository
from .bot_settings_repository import BotSettingsRepository
from .message_repository import MessageRepository
from .migration_repository import MigrationRepository
from .post_repository import PostRepository
from .user_repository import UserRepository
__all__ = [
"UserRepository",
"BlacklistRepository",
"BlacklistHistoryRepository",
"MessageRepository",
"PostRepository",
"AdminRepository",
"AudioRepository",
"MigrationRepository",
"BotSettingsRepository",
]

View File

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

View File

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

View File

@@ -0,0 +1,174 @@
from typing import Optional, Tuple
from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord
class BlacklistHistoryRepository(DatabaseConnection):
"""Репозиторий для работы с историей банов/разбанов."""
async def create_tables(self):
"""Создание таблицы истории банов/разбанов."""
query = """
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
)
"""
await self._execute_query(query)
# Создаем индексы
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
)
self.logger.info("Таблица истории банов/разбанов создана")
async def add_record_on_ban(self, record: BlacklistHistoryRecord) -> None:
"""Добавляет запись о бане в историю."""
query = """
INSERT INTO blacklist_history (
user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
# Используем текущее время, если не указано
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
params = (
record.user_id,
record.message_for_user,
record.date_ban,
record.date_unban,
record.ban_author,
record.created_at if record.created_at is not None else current_timestamp,
record.updated_at if record.updated_at is not None else current_timestamp,
)
await self._execute_query(query, params)
self.logger.info(
f"Запись о бане добавлена в историю: user_id={record.user_id}, "
f"date_ban={record.date_ban}"
)
async def set_unban_date(self, user_id: int, date_unban: int) -> bool:
"""
Обновляет date_unban и updated_at в последней записи (date_unban IS NULL) для пользователя.
Args:
user_id: ID пользователя
date_unban: Timestamp даты разбана
Returns:
True если запись обновлена, False если не найдена открытая запись
"""
try:
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
# SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос
# Сначала проверяем, есть ли открытая запись
check_query = """
SELECT id FROM blacklist_history
WHERE user_id = ? AND date_unban IS NULL
ORDER BY id DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(check_query, (user_id,))
if not rows:
self.logger.warning(
f"Не найдена открытая запись в истории для обновления: user_id={user_id}"
)
return False
# Обновляем найденную запись
update_query = """
UPDATE blacklist_history
SET date_unban = ?,
updated_at = ?
WHERE id = ?
"""
record_id = rows[0][0]
params = (date_unban, current_timestamp, record_id)
await self._execute_query(update_query, params)
self.logger.info(
f"Дата разбана обновлена в истории: user_id={user_id}, date_unban={date_unban}"
)
return True
except Exception as e:
self.logger.error(
f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}"
)
return False
async def get_ban_count(self, user_id: int) -> int:
"""
Получает количество банов пользователя за все время.
Args:
user_id: ID пользователя
Returns:
Количество банов
"""
query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
count = row[0] if row else 0
self.logger.info(f"Количество банов для user_id={user_id}: {count}")
return count
async def get_last_ban_info(
self, user_id: int
) -> Optional[Tuple[int, str, Optional[int]]]:
"""
Получает информацию о последнем бане пользователя.
Args:
user_id: ID пользователя
Returns:
Tuple (date_ban, reason, date_unban) или None, если банов не было
"""
query = """
SELECT date_ban, reason, date_unban FROM blacklist_history
WHERE user_id = ?
ORDER BY date_ban DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
date_ban = row[0]
reason = row[1]
date_unban = row[2]
self.logger.info(
f"Последний бан для user_id={user_id}: "
f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}"
)
return (date_ban, reason, date_unban)
self.logger.info(f"Банов для user_id={user_id} не найдено")
return None

View File

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

View File

@@ -0,0 +1,160 @@
"""Репозиторий для работы с настройками бота."""
from typing import Dict, Optional
from database.base import DatabaseConnection
class BotSettingsRepository(DatabaseConnection):
"""Репозиторий для управления настройками бота в таблице bot_settings."""
async def create_table(self) -> None:
"""Создает таблицу bot_settings, если она не существует."""
query = """
CREATE TABLE IF NOT EXISTS bot_settings (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"""
await self._execute_query(query)
self.logger.info("Таблица bot_settings создана или уже существует")
async def get_setting(self, key: str) -> Optional[str]:
"""
Получает значение настройки по ключу.
Args:
key: Ключ настройки
Returns:
Значение настройки или None, если не найдено
"""
query = "SELECT value FROM bot_settings WHERE key = ?"
rows = await self._execute_query_with_result(query, (key,))
if rows and len(rows) > 0:
return rows[0][0]
return None
async def set_setting(self, key: str, value: str) -> None:
"""
Устанавливает значение настройки.
Args:
key: Ключ настройки
value: Значение настройки
"""
query = """
INSERT INTO bot_settings (key, value, updated_at)
VALUES (?, ?, strftime('%s', 'now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%s', 'now')
"""
await self._execute_query(query, (key, value))
self.logger.debug(f"Настройка {key} установлена: {value}")
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
"""
Получает булево значение настройки.
Args:
key: Ключ настройки
default: Значение по умолчанию
Returns:
True если значение 'true', иначе False
"""
value = await self.get_setting(key)
if value is None:
return default
return value.lower() == "true"
async def set_bool_setting(self, key: str, value: bool) -> None:
"""
Устанавливает булево значение настройки.
Args:
key: Ключ настройки
value: Булево значение
"""
await self.set_setting(key, "true" if value else "false")
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
"""
Получает числовое значение настройки.
Args:
key: Ключ настройки
default: Значение по умолчанию
Returns:
Числовое значение или default
"""
value = await self.get_setting(key)
if value is None:
return default
try:
return float(value)
except ValueError:
self.logger.warning(
f"Невозможно преобразовать значение '{value}' в float для ключа '{key}'"
)
return default
async def set_float_setting(self, key: str, value: float) -> None:
"""
Устанавливает числовое значение настройки.
Args:
key: Ключ настройки
value: Числовое значение
"""
await self.set_setting(key, str(value))
async def get_auto_moderation_settings(self) -> Dict[str, any]:
"""
Получает все настройки авто-модерации.
Returns:
Словарь с настройками авто-модерации
"""
return {
"auto_publish_enabled": await self.get_bool_setting(
"auto_publish_enabled", False
),
"auto_decline_enabled": await self.get_bool_setting(
"auto_decline_enabled", False
),
"auto_publish_threshold": await self.get_float_setting(
"auto_publish_threshold", 0.8
),
"auto_decline_threshold": await self.get_float_setting(
"auto_decline_threshold", 0.4
),
}
async def toggle_auto_publish(self) -> bool:
"""
Переключает состояние авто-публикации.
Returns:
Новое состояние (True/False)
"""
current = await self.get_bool_setting("auto_publish_enabled", False)
new_value = not current
await self.set_bool_setting("auto_publish_enabled", new_value)
return new_value
async def toggle_auto_decline(self) -> bool:
"""
Переключает состояние авто-отклонения.
Returns:
Новое состояние (True/False)
"""
current = await self.get_bool_setting("auto_decline_enabled", False)
new_value = not current
await self.set_bool_setting("auto_decline_enabled", new_value)
return new_value

View File

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

View File

@@ -0,0 +1,80 @@
"""Репозиторий для работы с миграциями базы данных."""
import aiosqlite
from database.base import DatabaseConnection
class MigrationRepository(DatabaseConnection):
"""Репозиторий для управления миграциями базы данных."""
async def create_table(self):
"""Создает таблицу migrations, если она не существует."""
query = """
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL UNIQUE,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"""
await self._execute_query(query)
self.logger.info("Таблица migrations создана или уже существует")
async def get_applied_migrations(self) -> list[str]:
"""Возвращает список имен примененных скриптов миграций."""
conn = None
try:
conn = await self._get_connection()
cursor = await conn.execute(
"SELECT script_name FROM migrations ORDER BY applied_at"
)
rows = await cursor.fetchall()
await cursor.close()
return [row[0] for row in rows]
except Exception as e:
self.logger.error(f"Ошибка при получении списка миграций: {e}")
raise
finally:
if conn:
await conn.close()
async def is_migration_applied(self, script_name: str) -> bool:
"""Проверяет, применена ли миграция."""
conn = None
try:
conn = await self._get_connection()
cursor = await conn.execute(
"SELECT COUNT(*) FROM migrations WHERE script_name = ?", (script_name,)
)
row = await cursor.fetchone()
await cursor.close()
return row[0] > 0 if row else False
except Exception as e:
self.logger.error(f"Ошибка при проверке миграции {script_name}: {e}")
raise
finally:
if conn:
await conn.close()
async def mark_migration_applied(self, script_name: str) -> None:
"""Отмечает миграцию как примененную."""
conn = None
try:
conn = await self._get_connection()
await conn.execute(
"INSERT INTO migrations (script_name) VALUES (?)", (script_name,)
)
await conn.commit()
self.logger.info(f"Миграция {script_name} отмечена как примененная")
except aiosqlite.IntegrityError:
self.logger.warning(f"Миграция {script_name} уже была применена ранее")
except Exception as e:
self.logger.error(f"Ошибка при отметке миграции {script_name}: {e}")
raise
finally:
if conn:
await conn.close()
async def create_table_from_sql(self, sql_script: str) -> None:
"""Создает таблицу из SQL скрипта. Используется в миграциях."""
await self._execute_query(sql_script)

View File

@@ -0,0 +1,611 @@
from datetime import datetime
from typing import List, Optional, Tuple
from database.base import DatabaseConnection
from database.models import MessageContentLink, PostContent, TelegramPost
class PostRepository(DatabaseConnection):
"""Репозиторий для работы с постами из Telegram."""
async def create_tables(self):
"""Создание таблиц для постов."""
# Таблица постов из Telegram
post_query = """
CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
message_id INTEGER NOT NULL PRIMARY KEY,
text TEXT,
helper_text_message_id INTEGER,
author_id INTEGER,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
)
"""
await self._execute_query(post_query)
# Добавляем поле published_message_id если его нет (для существующих БД)
try:
check_column_query = """
SELECT name FROM pragma_table_info('post_from_telegram_suggest')
WHERE name = 'published_message_id'
"""
existing_columns = await self._execute_query_with_result(check_column_query)
if not existing_columns:
await self._execute_query(
"ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
)
self.logger.info(
"Столбец published_message_id добавлен в post_from_telegram_suggest"
)
except Exception as e:
# Если проверка не удалась, пытаемся добавить столбец (может быть уже существует)
try:
await self._execute_query(
"ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER"
)
self.logger.info(
"Столбец published_message_id добавлен в post_from_telegram_suggest (fallback)"
)
except Exception:
# Столбец уже существует, игнорируем ошибку
pass
# Таблица контента постов
content_query = """
CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
"""
await self._execute_query(content_query)
# Таблица связи сообщений с контентом
link_query = """
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest (message_id) ON DELETE CASCADE
)
"""
await self._execute_query(link_query)
# Таблица контента опубликованных постов
published_content_query = """
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
)
"""
await self._execute_query(published_content_query)
# Создаем индексы
try:
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id)"
)
except Exception:
# Индексы уже существуют, игнорируем ошибку
pass
self.logger.info("Таблицы для постов созданы")
async def add_post(self, post: TelegramPost) -> None:
"""Добавление поста."""
if not post.created_at:
post.created_at = int(datetime.now().timestamp())
status = post.status if post.status else "suggest"
# Преобразуем bool в int для SQLite (True -> 1, False -> 0, None -> None)
is_anonymous_int = (
None if post.is_anonymous is None else (1 if post.is_anonymous else 0)
)
# Используем INSERT OR IGNORE чтобы избежать ошибок при повторном создании
query = """
INSERT OR IGNORE INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous)
VALUES (?, ?, ?, ?, ?, ?)
"""
params = (
post.message_id,
post.text,
post.author_id,
post.created_at,
status,
is_anonymous_int,
)
await self._execute_query(query, params)
self.logger.info(
f"Пост добавлен (или уже существует): message_id={post.message_id}, text длина={len(post.text) if post.text else 0}, is_anonymous={is_anonymous_int}"
)
async def update_helper_message(
self, message_id: int, helper_message_id: int
) -> None:
"""Обновление helper сообщения."""
query = "UPDATE post_from_telegram_suggest SET helper_text_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (helper_message_id, message_id))
async def update_status_by_message_id(self, message_id: int, status: str) -> int:
"""Обновление статуса поста по message_id (одиночные посты). Возвращает число обновлённых строк."""
conn = None
try:
conn = await self._get_connection()
await conn.execute(
"UPDATE post_from_telegram_suggest SET status = ? WHERE message_id = ?",
(status, message_id),
)
cur = await conn.execute("SELECT changes()")
row = await cur.fetchone()
n = row[0] if row else 0
await conn.commit()
if n == 0:
self.logger.warning(
f"update_status_by_message_id: 0 строк обновлено для message_id={message_id}, status={status}"
)
else:
self.logger.info(
f"Статус поста message_id={message_id} обновлён на {status}"
)
return n
except Exception as e:
if conn:
await conn.rollback()
self.logger.error(
f"Ошибка при обновлении статуса message_id={message_id}: {e}"
)
raise
finally:
if conn:
await conn.close()
async def update_status_for_media_group_by_helper_id(
self, helper_message_id: int, status: str
) -> int:
"""Обновление статуса постов медиагруппы по helper_text_message_id. Возвращает число обновлённых строк."""
conn = None
try:
conn = await self._get_connection()
await conn.execute(
"""
UPDATE post_from_telegram_suggest
SET status = ?
WHERE message_id = ? OR helper_text_message_id = ?
""",
(status, helper_message_id, helper_message_id),
)
cur = await conn.execute("SELECT changes()")
row = await cur.fetchone()
n = row[0] if row else 0
await conn.commit()
if n == 0:
self.logger.warning(
f"update_status_for_media_group_by_helper_id: 0 строк обновлено "
f"для helper_message_id={helper_message_id}, status={status}"
)
else:
self.logger.info(
f"Статус медиагруппы helper_message_id={helper_message_id} обновлён на {status}"
)
return n
except Exception as e:
if conn:
await conn.rollback()
self.logger.error(
f"Ошибка при обновлении статуса медиагруппы helper_message_id={helper_message_id}: {e}"
)
raise
finally:
if conn:
await conn.close()
async def add_post_content(
self, post_id: int, message_id: int, content_name: str, content_type: str
) -> bool:
"""Добавление контента поста."""
try:
# Сначала добавляем связь
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id))
# Затем добавляем контент
content_query = """
INSERT OR IGNORE INTO content_post_from_telegram (message_id, content_name, content_type)
VALUES (?, ?, ?)
"""
await self._execute_query(
content_query, (message_id, content_name, content_type)
)
self.logger.info(
f"Контент поста добавлен: post_id={post_id}, message_id={message_id}"
)
return True
except Exception as e:
self.logger.error(f"Ошибка при добавлении контента поста: {e}")
return False
async def add_message_link(self, post_id: int, message_id: int) -> bool:
"""Добавляет связь между post_id и message_id в таблицу message_link_to_content."""
try:
self.logger.info(
f"Добавление связи: post_id={post_id}, message_id={message_id}"
)
link_query = "INSERT OR IGNORE INTO message_link_to_content (post_id, message_id) VALUES (?, ?)"
await self._execute_query(link_query, (post_id, message_id))
self.logger.info(
f"Связь успешно добавлена: post_id={post_id}, message_id={message_id}"
)
return True
except Exception as e:
self.logger.error(
f"Ошибка при добавлении связи post_id={post_id}, message_id={message_id}: {e}"
)
return False
async def get_post_content_by_helper_id(
self, helper_message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент поста по helper_text_message_id."""
query = """
SELECT cpft.content_name, cpft.content_type
FROM post_from_telegram_suggest pft
JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
WHERE pft.helper_text_message_id = ?
"""
post_content = await self._execute_query_with_result(
query, (helper_message_id,)
)
self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
return post_content
async def get_post_content_by_message_id(
self, message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент одиночного поста по message_id."""
query = """
SELECT cpft.content_name, cpft.content_type
FROM post_from_telegram_suggest pft
JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id
JOIN content_post_from_telegram cpft ON cpft.message_id = mltc.message_id
WHERE pft.message_id = ? AND pft.helper_text_message_id IS NULL
"""
post_content = await self._execute_query_with_result(query, (message_id,))
self.logger.info(
f"Получен контент одиночного поста: {len(post_content)} элементов для message_id={message_id}"
)
return post_content
async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id."""
query = "SELECT text FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None
if row:
self.logger.info(
f"Получен текст поста для helper_message_id={helper_message_id}"
)
return row[0]
return None
async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]:
"""Получает ID сообщений по helper_text_message_id."""
query = """
SELECT mltc.message_id
FROM post_from_telegram_suggest pft
JOIN message_link_to_content mltc ON pft.message_id = mltc.post_id
WHERE pft.helper_text_message_id = ?
"""
rows = await self._execute_query_with_result(query, (helper_message_id,))
post_ids = [row[0] for row in rows]
self.logger.info(f"Получены ID сообщений: {len(post_ids)} элементов")
return post_ids
async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]:
"""Получает ID автора по message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None
if row:
author_id = row[0]
self.logger.info(
f"Получен author_id: {author_id} для message_id={message_id}"
)
return author_id
return None
async def get_author_id_by_helper_message_id(
self, helper_message_id: int
) -> Optional[int]:
"""Получает ID автора по helper_text_message_id."""
query = "SELECT author_id FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None
if row:
author_id = row[0]
self.logger.info(
f"Получен author_id: {author_id} для helper_message_id={helper_message_id}"
)
return author_id
return None
async def get_post_text_and_anonymity_by_message_id(
self, message_id: int
) -> Tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по message_id."""
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
row = rows[0] if rows else None
if row:
text = row[0] or ""
is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
self.logger.info(
f"Получены текст и is_anonymous для message_id={message_id}"
)
return text, is_anonymous
return None, None
async def get_post_text_and_anonymity_by_helper_id(
self, helper_message_id: int
) -> Tuple[Optional[str], Optional[bool]]:
"""Получает текст и is_anonymous поста по helper_text_message_id."""
query = "SELECT text, is_anonymous FROM post_from_telegram_suggest WHERE helper_text_message_id = ?"
rows = await self._execute_query_with_result(query, (helper_message_id,))
row = rows[0] if rows else None
if row:
text = row[0] or ""
is_anonymous_int = row[1]
# Преобразуем int в bool (1 -> True, 0 -> False, NULL -> None)
is_anonymous = None if is_anonymous_int is None else bool(is_anonymous_int)
self.logger.info(
f"Получены текст и is_anonymous для helper_message_id={helper_message_id}"
)
return text, is_anonymous
return None, None
async def update_published_message_id(
self, original_message_id: int, published_message_id: int
) -> None:
"""Обновляет published_message_id для опубликованного поста."""
query = "UPDATE post_from_telegram_suggest SET published_message_id = ? WHERE message_id = ?"
await self._execute_query(query, (published_message_id, original_message_id))
self.logger.info(
f"Обновлен published_message_id: {original_message_id} -> {published_message_id}"
)
async def add_published_post_content(
self, published_message_id: int, content_path: str, content_type: str
) -> bool:
"""Добавляет контент опубликованного поста."""
try:
from datetime import datetime
published_at = int(datetime.now().timestamp())
query = """
INSERT OR IGNORE INTO published_post_content
(published_message_id, content_name, content_type, published_at)
VALUES (?, ?, ?, ?)
"""
await self._execute_query(
query, (published_message_id, content_path, content_type, published_at)
)
self.logger.info(
f"Добавлен контент опубликованного поста: published_message_id={published_message_id}, type={content_type}"
)
return True
except Exception as e:
self.logger.error(
f"Ошибка при добавлении контента опубликованного поста: {e}"
)
return False
async def get_published_post_content(
self, published_message_id: int
) -> List[Tuple[str, str]]:
"""Получает контент опубликованного поста."""
query = """
SELECT content_name, content_type
FROM published_post_content
WHERE published_message_id = ?
"""
post_content = await self._execute_query_with_result(
query, (published_message_id,)
)
self.logger.info(
f"Получен контент опубликованного поста: {len(post_content)} элементов для published_message_id={published_message_id}"
)
return post_content
# ============================================
# Методы для работы с ML-скорингом
# ============================================
async def update_ml_scores(self, message_id: int, ml_scores_json: str) -> bool:
"""
Обновляет ML-скоры для поста.
Args:
message_id: ID сообщения в группе модерации
ml_scores_json: JSON строка со скорами
Returns:
True если обновлено успешно
"""
try:
query = "UPDATE post_from_telegram_suggest SET ml_scores = ? WHERE message_id = ?"
await self._execute_query(query, (ml_scores_json, message_id))
self.logger.info(f"ML-скоры обновлены для message_id={message_id}")
return True
except Exception as e:
self.logger.error(
f"Ошибка обновления ML-скоров для message_id={message_id}: {e}"
)
return False
async def get_ml_scores_by_message_id(self, message_id: int) -> Optional[str]:
"""
Получает ML-скоры для поста.
Args:
message_id: ID сообщения
Returns:
JSON строка со скорами или None
"""
query = "SELECT ml_scores FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
if rows and rows[0][0]:
return rows[0][0]
return None
async def get_post_text_by_message_id(self, message_id: int) -> Optional[str]:
"""
Получает текст поста по message_id.
Args:
message_id: ID сообщения
Returns:
Текст поста или None
"""
query = "SELECT text FROM post_from_telegram_suggest WHERE message_id = ?"
rows = await self._execute_query_with_result(query, (message_id,))
if rows and rows[0][0]:
return rows[0][0]
return None
async def get_approved_posts_texts(self, limit: int = 1000) -> List[str]:
"""
Получает тексты опубликованных постов для обучения RAG.
Args:
limit: Максимальное количество постов
Returns:
Список текстов
"""
query = """
SELECT text FROM post_from_telegram_suggest
WHERE status = 'approved'
AND text IS NOT NULL
AND text != ''
AND text != '^'
ORDER BY created_at DESC
LIMIT ?
"""
rows = await self._execute_query_with_result(query, (limit,))
texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} опубликованных постов для обучения")
return texts
async def get_declined_posts_texts(self, limit: int = 1000) -> List[str]:
"""
Получает тексты отклоненных постов для обучения RAG.
Args:
limit: Максимальное количество постов
Returns:
Список текстов
"""
query = """
SELECT text FROM post_from_telegram_suggest
WHERE status = 'declined'
AND text IS NOT NULL
AND text != ''
AND text != '^'
ORDER BY created_at DESC
LIMIT ?
"""
rows = await self._execute_query_with_result(query, (limit,))
texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
return texts
async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]:
"""
Получает статистику постов пользователя.
Args:
user_id: ID пользователя
Returns:
Tuple (approved_count, declined_count, suggest_count)
"""
query = """
SELECT
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined,
SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest
FROM post_from_telegram_suggest
WHERE author_id = ? AND text != '^'
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
approved = row[0] or 0
declined = row[1] or 0
suggest = row[2] or 0
self.logger.info(
f"Статистика постов для user_id={user_id}: "
f"approved={approved}, declined={declined}, suggest={suggest}"
)
return (approved, declined, suggest)
return (0, 0, 0)
async def get_last_post_by_author(self, user_id: int) -> Optional[str]:
"""
Получает текст последнего поста пользователя.
Args:
user_id: ID пользователя
Returns:
Текст последнего поста или None, если постов нет
"""
query = """
SELECT text FROM post_from_telegram_suggest
WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^'
ORDER BY created_at DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
text = row[0]
self.logger.info(
f"Последний пост для user_id={user_id}: '{text[:50]}...'"
if len(text) > 50
else f"Последний пост для user_id={user_id}: '{text}'"
)
return text
self.logger.info(f"Постов для user_id={user_id} не найдено")
return None

View File

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

View File

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

View File

@@ -1,17 +1,18 @@
-- Telegram Helper Bot Database Schema
-- Compatible with Docker container deployment
-- System table for SQLite auto-increment sequences
CREATE TABLE IF NOT EXISTS sqlite_sequence (
name TEXT NOT NULL,
seq INTEGER NOT NULL
);
-- IMPORTANT: Enable foreign key support after each database connection
-- PRAGMA foreign_keys = ON;
-- Note: sqlite_sequence table is automatically created by SQLite for AUTOINCREMENT fields
-- No need to create it manually
-- Users who have listened to audio messages
CREATE TABLE IF NOT EXISTS listen_audio_users (
CREATE TABLE IF NOT EXISTS user_audio_listens (
file_name TEXT NOT NULL,
user_id INTEGER NOT NULL,
is_listen BOOLEAN NOT NULL DEFAULT 0
PRIMARY KEY (file_name, user_id),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Reference table for audio messages
@@ -19,30 +20,38 @@ CREATE TABLE IF NOT EXISTS audio_message_reference (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL UNIQUE,
author_id INTEGER NOT NULL,
date_added DATE NOT NULL,
listen_count INTEGER NOT NULL DEFAULT 0,
file_id INTEGER NOT NULL
);
-- Database migrations tracking
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER NOT NULL PRIMARY KEY,
script_name TEXT NOT NULL,
created_at TEXT NOT NULL
date_added INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Bot administrators
CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER NOT NULL PRIMARY KEY,
role TEXT
role TEXT,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- User blacklist for banned users
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
user_name TEXT,
message_for_user TEXT,
date_to_unban INTEGER
date_to_unban INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Blacklist history for tracking all ban/unban events
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
);
-- User message history
@@ -50,8 +59,9 @@ CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_text TEXT,
user_id INTEGER,
message_id INTEGER NOT NULL,
date TEXT NOT NULL
telegram_message_id INTEGER NOT NULL,
date INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Suggested posts from Telegram
@@ -60,14 +70,19 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
text TEXT,
helper_text_message_id INTEGER,
author_id INTEGER,
created_at TEXT NOT NULL
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Links between posts and content
CREATE TABLE IF NOT EXISTS message_link_to_content (
post_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
PRIMARY KEY (post_id, message_id)
PRIMARY KEY (post_id, message_id),
FOREIGN KEY (post_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
);
-- Content associated with Telegram posts
@@ -75,36 +90,62 @@ CREATE TABLE IF NOT EXISTS content_post_from_telegram (
message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
PRIMARY KEY (message_id, content_name)
PRIMARY KEY (message_id, content_name),
FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE
);
-- Bot users information
-- Content of published posts
CREATE TABLE IF NOT EXISTS published_post_content (
published_message_id INTEGER NOT NULL,
content_name TEXT NOT NULL,
content_type TEXT,
published_at INTEGER NOT NULL,
PRIMARY KEY (published_message_id, content_name)
);
-- Bot users information (user_id is now PRIMARY KEY)
CREATE TABLE IF NOT EXISTS our_users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
full_name TEXT,
username TEXT,
is_bot BOOLEAN DEFAULT 0,
language_code TEXT,
has_stickers INTEGER DEFAULT 0 NOT NULL,
has_stickers BOOLEAN DEFAULT 0 NOT NULL,
emoji TEXT,
date_added DATE NOT NULL,
date_changed DATE NOT NULL
date_added INTEGER NOT NULL,
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
);
-- Audio moderation tracking
CREATE TABLE IF NOT EXISTS audio_moderate (
user_id INTEGER NOT NULL,
message_id INTEGER,
PRIMARY KEY (user_id, message_id)
PRIMARY KEY (user_id, message_id),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Database migrations tracking
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL UNIQUE,
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_listen_audio_users_file_name ON listen_audio_users(file_name);
CREATE INDEX IF NOT EXISTS idx_listen_audio_users_user_id ON listen_audio_users(user_id);
-- Optimized index for user_audio_listens - only user_id for "show all audio listened by user X"
CREATE INDEX IF NOT EXISTS idx_user_audio_listens_user_id ON user_audio_listens(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_author_id ON audio_message_reference(author_id);
CREATE INDEX IF NOT EXISTS idx_user_messages_user_id ON user_messages(user_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_author_id ON post_from_telegram_suggest(author_id);
CREATE INDEX IF NOT EXISTS idx_our_users_user_id ON our_users(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_moderate_user_id ON audio_moderate(user_id);
CREATE INDEX IF NOT EXISTS idx_blacklist_date_to_unban ON blacklist(date_to_unban);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban);
CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);
CREATE INDEX IF NOT EXISTS idx_our_users_date_changed ON our_users(date_changed);
CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id);

View File

@@ -1,130 +0,0 @@
version: '3.8'
services:
telegram-bot:
build:
context: .
dockerfile: Dockerfile.bot
container_name: telegram-bot
restart: unless-stopped
expose:
- "8000"
environment:
- PYTHONPATH=/app
- DOCKER_CONTAINER=true
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30}
- METRICS_HOST=${METRICS_HOST:-0.0.0.0}
- METRICS_PORT=${METRICS_PORT:-8000}
# Telegram settings
- TELEGRAM_BOT_TOKEN=${BOT_TOKEN}
- TELEGRAM_LISTEN_BOT_TOKEN=${LISTEN_BOT_TOKEN}
- TELEGRAM_TEST_BOT_TOKEN=${TEST_BOT_TOKEN}
- TELEGRAM_PREVIEW_LINK=${PREVIEW_LINK:-false}
- TELEGRAM_MAIN_PUBLIC=${MAIN_PUBLIC}
- TELEGRAM_GROUP_FOR_POSTS=${GROUP_FOR_POSTS}
- TELEGRAM_GROUP_FOR_MESSAGE=${GROUP_FOR_MESSAGE}
- TELEGRAM_GROUP_FOR_LOGS=${GROUP_FOR_LOGS}
- TELEGRAM_IMPORTANT_LOGS=${IMPORTANT_LOGS}
- TELEGRAM_ARCHIVE=${ARCHIVE}
- TELEGRAM_TEST_GROUP=${TEST_GROUP}
# Bot settings
- SETTINGS_LOGS=${LOGS:-false}
- SETTINGS_TEST=${TEST:-false}
# Database
- DATABASE_PATH=${DATABASE_PATH:-database/tg-bot-database.db}
volumes:
- ./database:/app/database:rw
- ./logs:/app/logs:rw
- ./.env:/app/.env:ro
networks:
- bot-internal
depends_on:
- prometheus
- grafana
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
prometheus:
image: prom/prometheus:latest
container_name: prometheus
expose:
- "9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--storage.tsdb.retention.time=200h'
- '--web.enable-lifecycle'
restart: unless-stopped
networks:
- bot-internal
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3000:3000" # Grafana доступна извне
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=http://localhost:3000
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./grafana/datasources:/etc/grafana/provisioning/datasources:ro
restart: unless-stopped
networks:
- bot-internal
depends_on:
- prometheus
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
volumes:
prometheus_data:
driver: local
grafana_data:
driver: local
networks:
bot-internal:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

710
docs/IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,710 @@
# План улучшений проекта
Этот документ содержит список рекомендаций по улучшению кодовой базы проекта Telegram Helper Bot. Пункты отсортированы по приоритетам и могут быть использованы для планирования работ.
## Статус задач
-Не начато
- 🟡 В работе
- ✅ Выполнено
- ❌ Отложено
---
## 🔴 Высокий приоритет
### 1. Стандартизация Dependency Injection
**Статус:**
**Проблема:**
В проекте используется смешанный подход к dependency injection:
- В некоторых местах используется `MagicData("bot_db")` и `MagicData("settings")`
- В других местах используется `**kwargs` и получение из `data`
- В сервисах напрямую вызывается `get_global_instance()`
**Текущее состояние:**
```python
# callback_handlers.py - смешанный подход
async def handler(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service() # Прямой вызов фабрики
async def handler(call: CallbackQuery, **kwargs):
ban_service = get_ban_service() # Прямой вызов фабрики
```
**Рекомендация:**
Стандартизировать на использование `MagicData` и `Annotated` везде:
```python
from typing import Annotated
from aiogram.filters import MagicData
from helper_bot.handlers.admin.dependencies import BotDB, Settings
async def handler(
call: CallbackQuery,
bot_db: Annotated[AsyncBotDB, BotDB],
settings: Annotated[dict, Settings],
service: Annotated[PostPublishService, get_post_publish_service()]
):
# Использовать зависимости напрямую
...
```
**Файлы для изменения:**
- `helper_bot/handlers/callback/callback_handlers.py` (строки 47, 80, 109, 131, 182)
- `helper_bot/handlers/private/private_handlers.py`
- Все сервисы, которые используют `get_global_instance()`
**Оценка:** Средняя сложность, требует рефакторинга нескольких файлов
---
### 2. Удаление `import *`
**Статус:**
**Проблема:**
В `voice_handler.py` используется импорт всех констант через `import *`, что затрудняет понимание зависимостей и может привести к конфликтам имен.
**Текущее состояние:**
```python
# helper_bot/handlers/voice/voice_handler.py
from helper_bot.handlers.voice.constants import *
```
**Рекомендация:**
Заменить на явные импорты:
```python
from helper_bot.handlers.voice.constants import (
CONSTANT1,
CONSTANT2,
CONSTANT3,
# ... все используемые константы
)
```
**Файлы для изменения:**
- `helper_bot/handlers/voice/voice_handler.py` (строка 17)
**Оценка:** Низкая сложность, быстрое исправление
---
### 3. Закрытие критичных TODO
**Статус:**
**Проблема:**
В коде есть несколько TODO комментариев, указывающих на технический долг и места, требующие рефакторинга.
**Список TODO:**
#### 3.1. Callback handlers - переход на MagicData
**Файл:** `helper_bot/handlers/callback/callback_handlers.py`
- Строка 47: `# TODO: переделать на MagicData`
- Строка 80: `# TODO: переделать на MagicData`
- Строка 109: `# TODO: переделать на MagicData`
- Строка 131: `# TODO: переделать на MagicData`
- Строка 182: `# TODO: переделать на MagicData`
**Решение:** Связано с задачей #1 (стандартизация DI)
#### 3.2. Metrics middleware - подключение к БД
**Файл:** `helper_bot/middlewares/metrics_middleware.py`
- Строка 153: `#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру`
**Решение:**
```python
# Вместо
bdf = get_global_instance()
bot_db = bdf.get_db()
# Использовать dependency injection через MagicData
async def _update_active_users_metric(
self,
bot_db: Annotated[AsyncBotDB, BotDB]
):
...
```
#### 3.3. Voice handler - вынос логики
**Файл:** `helper_bot/handlers/voice/voice_handler.py`
- Строка 354: `#TODO: удалить логику из хендлера`
**Решение:** Переместить бизнес-логику в `VoiceBotService`
#### 3.4. Helper functions - архитектура
**Файл:** `helper_bot/utils/helper_func.py`
- Строка 35: `#TODO: поменять архитектуру и подключить правильный BotDB`
- Строка 145: `#TODO: Уверен можно укоротить`
**Решение:** Рефакторинг функций для использования dependency injection
#### 3.5. Group handlers - архитектура
**Файл:** `helper_bot/handlers/group/group_handlers.py`
- Строка 109: `#TODO: поменять архитектуру и подключить правильный BotDB`
**Решение:** Использовать dependency injection вместо прямого доступа к БД
**Оценка:** Средняя-высокая сложность, требует анализа каждого случая
---
## 🟡 Средний приоритет
### 4. Оптимизация работы с БД - Connection Pooling
**Статус:**
**Проблема:**
Каждый запрос к БД открывает новое соединение и закрывает его. При высокой нагрузке это неэффективно и может привести к проблемам с производительностью.
**Текущее состояние:**
```python
# database/base.py
async def _get_connection(self):
conn = await aiosqlite.connect(self.db_path)
# Настройка PRAGMA каждый раз
await conn.execute("PRAGMA foreign_keys = ON")
await conn.execute("PRAGMA journal_mode = WAL")
# ...
return conn
async def _execute_query(self, query: str, params: tuple = ()):
conn = None
try:
conn = await self._get_connection() # Новое соединение каждый раз
result = await conn.execute(query, params)
await conn.commit()
return result
finally:
if conn:
await conn.close() # Закрытие после каждого запроса
```
**Рекомендация:**
Реализовать переиспользование соединений или connection pool:
**Вариант 1: Переиспользование соединения в рамках транзакции**
```python
class DatabaseConnection:
def __init__(self, db_path: str):
self.db_path = db_path
self._connection: Optional[aiosqlite.Connection] = None
async def _get_connection(self):
if self._connection is None:
self._connection = await aiosqlite.connect(self.db_path)
# Настройка PRAGMA один раз
await self._connection.execute("PRAGMA foreign_keys = ON")
# ...
return self._connection
async def close(self):
if self._connection:
await self._connection.close()
self._connection = None
```
**Вариант 2: Использование async context manager**
```python
async def _execute_query(self, query: str, params: tuple = ()):
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
result = await conn.execute(query, params)
await conn.commit()
return result
```
**Файлы для изменения:**
- `database/base.py`
- `database/repository_factory.py` (добавить метод `close()`)
- `helper_bot/utils/base_dependency_factory.py` (закрытие соединений при shutdown)
**Оценка:** Средняя сложность, требует тестирования на производительность
---
### 5. Улучшение обработки ошибок - декораторы
**Статус:**
**Проблема:**
В `callback_handlers.py` повторяется один и тот же блок обработки ошибок в каждом handler:
```python
try:
# Бизнес-логика
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
)
logger.error(f'Неожиданная ошибка: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
```
**Рекомендация:**
Создать декоратор для централизованной обработки ошибок:
```python
# helper_bot/handlers/callback/decorators.py
from functools import wraps
from typing import Callable, Any
from aiogram.types import CallbackQuery
from logs.custom_logger import logger
import traceback
def handle_callback_errors(func: Callable[..., Any]) -> Callable[..., Any]:
"""Декоратор для обработки ошибок в callback handlers."""
@wraps(func)
async def wrapper(call: CallbackQuery, *args, **kwargs):
try:
return await func(call, *args, **kwargs)
except UserBlockedBotError:
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка в {func.__name__}: {str(e)}')
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
else:
# Получить settings из kwargs или через dependency injection
settings = kwargs.get('settings')
if settings:
important_logs = settings['Telegram']['important_logs']
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
)
logger.error(f'Неожиданная ошибка в {func.__name__}: {str(e)}')
await call.answer(
text=MESSAGE_ERROR,
show_alert=True,
cache_time=3
)
return wrapper
```
**Использование:**
```python
@callback_router.callback_query(F.data == CALLBACK_APPROVE)
@handle_callback_errors
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
async def post_for_group(call: CallbackQuery, ...):
# Только бизнес-логика, без try-except
publish_service = get_post_publish_service()
await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
```
**Файлы для изменения:**
- Создать `helper_bot/handlers/callback/decorators.py`
- Рефакторинг `helper_bot/handlers/callback/callback_handlers.py`
**Оценка:** Средняя сложность, требует тестирования всех сценариев
---
### 6. Валидация настроек при старте
**Статус:**
**Проблема:**
Настройки загружаются из `.env` без валидации. Отсутствие обязательных настроек обнаруживается только во время выполнения, что затрудняет отладку.
**Текущее состояние:**
```python
# helper_bot/utils/base_dependency_factory.py
def _load_settings_from_env(self):
self.settings['Telegram'] = {
'bot_token': os.getenv('BOT_TOKEN', ''), # Может быть пустой строкой
# ...
}
```
**Рекомендация:**
Добавить валидацию обязательных настроек:
```python
class BaseDependencyFactory:
REQUIRED_SETTINGS = {
'Telegram': ['bot_token'],
'S3': ['endpoint_url', 'access_key', 'secret_key', 'bucket_name'] # Если S3 включен
}
def _validate_settings(self):
"""Валидирует обязательные настройки."""
errors = []
# Проверка Telegram настроек
for key in self.REQUIRED_SETTINGS['Telegram']:
value = self.settings['Telegram'].get(key)
if not value:
errors.append(f"Telegram.{key} is required but not set")
# Проверка S3 настроек (если включен)
if self.settings['S3']['enabled']:
for key in self.REQUIRED_SETTINGS['S3']:
value = self.settings['S3'].get(key)
if not value:
errors.append(f"S3.{key} is required when S3 is enabled but not set")
if errors:
error_msg = "Configuration errors:\n" + "\n".join(f" - {e}" for e in errors)
raise ValueError(error_msg)
def __init__(self):
# ... существующий код ...
self._load_settings_from_env()
self._validate_settings() # Добавить валидацию
self._init_s3_storage()
```
**Файлы для изменения:**
- `helper_bot/utils/base_dependency_factory.py`
**Оценка:** Низкая сложность, быстрое добавление
---
### 7. Исправление RepositoryFactory
**Статус:**
**Проблема:**
Методы `check_database_integrity()` и `cleanup_wal_files()` в `RepositoryFactory` вызываются только для репозитория `users`, хотя должны применяться ко всем репозиториям или к базе данных в целом.
**Текущее состояние:**
```python
# database/repository_factory.py
async def check_database_integrity(self):
"""Проверяет целостность базы данных."""
await self.users.check_database_integrity() # Только users?
async def cleanup_wal_files(self):
"""Очищает WAL файлы."""
await self.users.cleanup_wal_files() # Только users?
```
**Рекомендация:**
Проверка целостности и очистка WAL должны выполняться один раз для всей БД, а не для каждого репозитория:
```python
async def check_database_integrity(self):
"""Проверяет целостность базы данных."""
# Использовать любой репозиторий для доступа к БД
await self.users.check_database_integrity()
async def cleanup_wal_files(self):
"""Очищает WAL файлы."""
# Использовать любой репозиторий для доступа к БД
await self.users.cleanup_wal_files()
```
Или лучше - вынести эти методы в `DatabaseConnection` и вызывать через любой репозиторий (текущая реализация уже правильная, но можно улучшить документацию).
**Альтернатива:** Создать отдельный класс `DatabaseManager` для операций на уровне БД.
**Файлы для изменения:**
- `database/repository_factory.py` (улучшить документацию)
- Возможно создать `database/database_manager.py`
**Оценка:** Низкая сложность, в основном документация
---
## 🟢 Низкий приоритет
### 8. Добавление кэширования (Redis)
**Статус:**
**Проблема:**
Часто запрашиваемые данные (например, список администраторов, настройки пользователей) загружаются из БД при каждом запросе, что создает лишнюю нагрузку на базу данных.
**Рекомендация:**
Добавить Redis для кэширования часто используемых данных:
```python
# helper_bot/utils/cache.py
import redis.asyncio as redis
from typing import Optional, Any
import json
from helper_bot.utils.base_dependency_factory import get_global_instance
class CacheService:
def __init__(self):
bdf = get_global_instance()
settings = bdf.get_settings()
self.redis_client = None
if settings.get('Redis', {}).get('enabled', False):
self.redis_client = redis.from_url(
settings['Redis']['url'],
decode_responses=True
)
async def get(self, key: str) -> Optional[Any]:
"""Получить значение из кэша."""
if not self.redis_client:
return None
try:
value = await self.redis_client.get(key)
if value:
return json.loads(value)
except Exception as e:
logger.error(f"Ошибка получения из кэша: {e}")
return None
async def set(self, key: str, value: Any, ttl: int = 3600):
"""Установить значение в кэш."""
if not self.redis_client:
return
try:
await self.redis_client.setex(
key,
ttl,
json.dumps(value)
)
except Exception as e:
logger.error(f"Ошибка записи в кэш: {e}")
async def delete(self, key: str):
"""Удалить значение из кэша."""
if not self.redis_client:
return
try:
await self.redis_client.delete(key)
except Exception as e:
logger.error(f"Ошибка удаления из кэша: {e}")
```
**Использование:**
```python
# В репозиториях или сервисах
cache = CacheService()
# Получение с кэшированием
async def get_admin_list(self):
cache_key = "admin_list"
cached = await cache.get(cache_key)
if cached:
return cached
# Загрузка из БД
admins = await self._load_from_db()
# Сохранение в кэш на 1 час
await cache.set(cache_key, admins, ttl=3600)
return admins
```
**Данные для кэширования:**
- Список администраторов
- Настройки пользователей (если редко меняются)
- Статистика (активные пользователи за день)
- Черный список (с коротким TTL)
**Файлы для изменения:**
- Создать `helper_bot/utils/cache.py`
- Добавить настройки Redis в `BaseDependencyFactory`
- Обновить репозитории для использования кэша
**Оценка:** Средняя сложность, требует настройки Redis инфраструктуры
---
### 9. Улучшение Type Hints
**Статус:**
**Проблема:**
Некоторые методы возвращают `dict` без указания структуры, что затрудняет понимание API и использование IDE.
**Пример:**
```python
def get_settings(self):
return self.settings # Какой тип? Dict[str, Any]?
```
**Рекомендация:**
Использовать `TypedDict` для структурированных словарей:
```python
from typing import TypedDict, Dict, Any
class TelegramSettings(TypedDict):
bot_token: str
listen_bot_token: str
preview_link: bool
main_public: str
group_for_posts: int
# ...
class SettingsDict(TypedDict):
Telegram: TelegramSettings
Settings: Dict[str, bool]
Metrics: Dict[str, Any]
S3: Dict[str, Any]
class BaseDependencyFactory:
def get_settings(self) -> SettingsDict:
return self.settings
```
**Файлы для изменения:**
- `helper_bot/utils/base_dependency_factory.py`
- Создать `helper_bot/utils/types.py` для типов
**Оценка:** Средняя сложность, требует обновления всех мест использования
---
### 10. Расширение тестового покрытия
**Статус:**
**Проблема:**
Некоторые компоненты не покрыты тестами или имеют недостаточное покрытие.
**Рекомендация:**
Добавить тесты для:
1. **Middleware:**
- `DependenciesMiddleware` - проверка внедрения зависимостей
- `BlacklistMiddleware` - проверка блокировки пользователей
- `RateLimitMiddleware` - проверка ограничений
2. **BaseDependencyFactory:**
- Инициализация с валидными настройками
- Инициализация с невалидными настройками
- Получение зависимостей
3. **Интеграционные тесты:**
- Полные сценарии обработки сообщений
- Сценарии с ошибками
- Сценарии с rate limiting
**Файлы для создания:**
- `tests/test_dependencies_middleware.py`
- `tests/test_base_dependency_factory.py`
- `tests/test_integration_handlers.py`
**Оценка:** Высокая сложность, требует времени на написание тестов
---
### 11. Улучшение логирования
**Статус:**
**Проблема:**
В коде много `logger.info()` там, где можно использовать `logger.debug()` для детальной отладки. Это приводит к засорению логов в production.
**Рекомендация:**
Пересмотреть уровни логирования:
- `logger.debug()` - детальная отладочная информация (шаги выполнения, промежуточные значения)
- `logger.info()` - важные события (старт/остановка бота, критические действия пользователей)
- `logger.warning()` - предупреждения (нестандартные ситуации, которые не критичны)
- `logger.error()` - ошибки (исключения, сбои)
**Примеры для изменения:**
```python
# Было
logger.info(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
# Стало
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
```
**Файлы для изменения:**
- Все файлы с избыточным `logger.info()`
**Оценка:** Низкая сложность, но требует времени на ревью всех логов
---
### 12. Документация проекта
**Статус:**
**Проблема:**
Отсутствует общая документация проекта, что затрудняет onboarding новых разработчиков.
**Рекомендация:**
Создать следующие документы:
1. **README.md** (в корне проекта):
- Описание проекта
- Требования
- Установка и настройка
- Запуск
- Структура проекта
2. **docs/ARCHITECTURE.md**:
- Детальное описание архитектуры
- Диаграммы компонентов
- Паттерны проектирования
3. **docs/DEPLOYMENT.md**:
- Инструкции по развертыванию
- Настройка окружения
- Мониторинг
4. **docs/DEVELOPMENT.md**:
- Руководство для разработчиков
- Процесс разработки
- Code style guide (ссылка на .cursor/rules)
**Оценка:** Средняя сложность, требует времени на написание
---
## 📊 Статистика
- **Всего задач:** 12
- **Высокий приоритет:** 3
- **Средний приоритет:** 4
- **Низкий приоритет:** 5
## 📝 Заметки
- Большинство задач высокого приоритета связаны между собой (стандартизация DI решит несколько TODO)
- Задачи среднего приоритета улучшают производительность и качество кода
- Задачи низкого приоритета улучшают developer experience и поддерживаемость
## 🔄 Обновления
- **2026-01-25:** Создан первоначальный список улучшений на основе анализа кодовой базы
- **2026-01-25:** Добавлена задача #8 по кэшированию (Redis)
- **2026-01-25:** Создан документ `PYTHON_VERSION_MANAGEMENT.md` с рекомендациями по унификации версий Python

309
docs/OPERATIONS.md Normal file
View File

@@ -0,0 +1,309 @@
# Операционные команды для управления ботом
> **⚠️ ВАЖНО:** Все команды выполняются из корневой директории проекта
## 🔧 Основные команды
### Запуск и остановка
```bash
# Запустить всю инфраструктуру (Prometheus + Бот)
docker-compose up -d
# Запустить только бота
docker-compose up -d telegram-bot
# Запустить только Prometheus
docker-compose up -d prometheus
# Остановить все сервисы
docker-compose down
# Остановить только бота
docker-compose stop telegram-bot
# Остановить только Prometheus
docker-compose stop prometheus
```
### Сборка
```bash
# Собрать все контейнеры
docker-compose build
# Собрать только бота
docker-compose build telegram-bot
# Собрать только Prometheus
docker-compose build prometheus
# Пересобрать и запустить все
docker-compose up -d --build
# Пересобрать и запустить только бота
docker-compose up -d --build telegram-bot
```
## 📊 Мониторинг и логи
### Просмотр логов
```bash
# Логи всех сервисов
docker-compose logs -f
# Логи только бота
docker-compose logs -f telegram-bot
# Логи Prometheus
docker-compose logs -f prometheus
# Логи в реальном времени (последние 100 строк)
docker-compose logs -f --tail=100
```
### Статус и здоровье
```bash
# Статус всех контейнеров
docker-compose ps
# Проверить здоровье всех сервисов
docker-compose ps | grep -E "(unhealthy|starting)"
# Проверить здоровье бота
curl -f http://localhost:8080/health || echo "❌ Бот недоступен"
# Проверить здоровье Prometheus
curl -f http://localhost:9090/-/healthy || echo "❌ Prometheus недоступен"
```
## 🔄 Управление сервисами
### Перезапуск
```bash
# Перезапустить все сервисы
docker-compose restart
# Перезапустить только бота
docker-compose restart telegram-bot
# Перезапустить только Prometheus
docker-compose restart prometheus
```
### Обновление
```bash
# Обновить код и перезапустить
git pull origin main && docker-compose up -d --build
# Обновить только бота
git pull origin main && docker-compose up -d --build telegram-bot
# Обновить только Prometheus
docker-compose pull prometheus && docker-compose up -d prometheus
```
## 🧪 Тестирование
### Запуск тестов
```bash
# Запустить все тесты
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest"
# Тесты с покрытием
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"
# Тесты только бота
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
# Тесты с HTML отчетом
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest --cov=helper_bot --cov-report=html"
# Тесты конкретного модуля
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/handlers/admin/"
```
## 🛠️ Разработка
### Отладка
```bash
# Проверить версию Python в контейнере
docker exec bots_telegram_bot python --version
# Открыть shell в контейнере бота
docker exec -it bots_telegram_bot sh
# Проверить установленные пакеты
docker exec bots_telegram_bot pip list
# Проверить переменные окружения
docker exec bots_telegram_bot env | grep TELEGRAM
# Проверить логи бота в реальном времени
docker exec bots_telegram_bot tail -f /app/logs/helper_bot_$(date +%Y-%m-%d).log
```
### База данных
```bash
# Создать backup базы
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
# Восстановить из backup
tar -xzf backup-20241231-120000.tar.gz
# Подключиться к базе данных
docker exec -it bots_telegram_bot sqlite3 /app/database/tg-bot-database.db
# Проверить размер базы данных
docker exec bots_telegram_bot ls -lh /app/database/tg-bot-database.db
# Очистить логи (⚠️ ОСТОРОЖНО!)
docker exec bots_telegram_bot find /app/logs -name "*.log" -mtime +7 -delete
```
## 🚨 Аварийные ситуации
### Диагностика
```bash
# Проверить использование ресурсов
docker stats --no-stream
# Проверить сетевые соединения
docker network ls
docker network inspect prod_bots_network
# Проверить логи ошибок
docker-compose logs | grep -i error
# Проверить использование диска
docker system df
# Проверить свободное место
docker exec bots_telegram_bot df -h
```
### Восстановление
```bash
# Принудительная перезагрузка всех сервисов
docker-compose down && docker-compose up -d
# Очистка всех контейнеров и образов
docker-compose down -v --rmi all
docker system prune -f
# Восстановление из последнего backup
ls -t backup-*.tar.gz | head -1 | xargs -I {} tar -xzf {}
# Принудительная перезагрузка только бота
docker-compose stop telegram-bot
docker-compose rm -f telegram-bot
docker-compose up -d --build telegram-bot
```
## 📱 Доступ к сервисам
### Веб-интерфейсы
- **Prometheus**: http://localhost:9090
- **Бот Health**: http://localhost:8080/health
- **Бот Metrics**: http://localhost:8080/metrics
### Полезные команды
```bash
# Открыть Prometheus в браузере
open http://localhost:9090
# Открыть метрики бота в браузере
open http://localhost:8080/metrics
# Открыть health check бота в браузере
open http://localhost:8080/health
# Проверить доступность сервисов
curl -s http://localhost:8080/health | jq . || echo "Бот недоступен"
curl -s http://localhost:9090/-/healthy || echo "Prometheus недоступен"
```
## 🔍 Отладка проблем
### Частые проблемы
```bash
# Бот не отвечает
docker-compose restart telegram-bot && docker-compose logs -f telegram-bot
# Prometheus недоступен
docker-compose restart prometheus && curl -f http://localhost:9090/-/healthy
# Проблемы с базой данных
docker exec bots_telegram_bot sqlite3 /app/database/tg-bot-database.db ".tables"
# Проблемы с сетью
docker network inspect prod_bots_network
# Проблемы с правами доступа
docker exec bots_telegram_bot ls -la /app/database/
docker exec bots_telegram_bot ls -la /app/logs/
```
### Полезные alias'ы для .bashrc/.zshrc
```bash
# Добавить в ~/.bashrc или ~/.zshrc
alias bot='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose'
alias bot-logs='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f telegram-bot'
alias bot-restart='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose restart telegram-bot'
alias bot-status='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose ps'
alias bot-shell='cd /Users/andrejkatyhin/PycharmProjects/prod && docker exec -it bots_telegram_bot sh'
alias prometheus='cd /Users/andrejkatyhin/PycharmProjects/prod && docker-compose logs -f prometheus'
```
## 📝 Примеры использования
### Типичный workflow разработки
```bash
# 1. Внести изменения в код
cd /Users/andrejkatyhin/PycharmProjects/prod/bots/telegram-helper-bot
# ... редактируем код ...
# 2. Пересобрать и перезапустить бота
cd /Users/andrejkatyhin/PycharmProjects/prod
docker-compose up -d --build telegram-bot
# 3. Проверить логи
docker-compose logs -f telegram-bot
# 4. Запустить тесты
docker-compose run --rm telegram-bot sh -c "pip install --no-cache-dir -r requirements-dev.txt && pytest helper_bot/"
```
### Мониторинг в продакшене
```bash
# Проверить здоровье всех сервисов
docker-compose ps | grep -E "(unhealthy|starting)"
# Посмотреть статус
docker-compose ps
# Проверить логи ошибок
docker-compose logs | grep -i error
# Создать backup
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" database/ logs/ .env
# Проверить метрики
curl -s http://localhost:8080/metrics | head -20
```
### Отладка проблем
```bash
# Бот не запускается
docker-compose logs telegram-bot
# Проверить конфигурацию
docker exec bots_telegram_bot cat /app/.env
# Проверить права доступа к файлам
docker exec bots_telegram_bot ls -la /app/
# Проверить сетевые соединения
docker exec bots_telegram_bot netstat -tulpn
# Проверить процессы
docker exec bots_telegram_bot ps aux
```

View File

@@ -12,6 +12,14 @@ IMPORTANT_LOGS=-1001234567890
ARCHIVE=-1001234567890
TEST_GROUP=-1001234567890
# S3 Storage (для хранения медиафайлов опубликованных постов)
S3_ENABLED=false
S3_ENDPOINT_URL=https://api.s3.ru
S3_ACCESS_KEY=your_s3_access_key_here
S3_SECRET_KEY=your_s3_secret_key_here
S3_BUCKET_NAME=your_s3_bucket_name
S3_REGION=us-east-1
# Bot Settings
PREVIEW_LINK=false
LOGS=false
@@ -20,10 +28,26 @@ TEST=false
# Database
DATABASE_PATH=database/tg-bot-database.db
# Monitoring
# Monitoring (Centralized Prometheus)
METRICS_HOST=0.0.0.0
METRICS_PORT=8000
METRICS_PORT=8080
# Logging
LOG_LEVEL=INFO
LOG_RETENTION_DAYS=30
# ML Scoring - RAG API
# Включает оценку постов через внешний RAG API сервис
RAG_ENABLED=false
RAG_API_URL=http://xx.xxx.xx.xx/api/v1
RAG_API_KEY=your_rag_api_key_here
RAG_API_TIMEOUT=30
RAG_TEST_MODE=false
# ML Scoring - DeepSeek API
# Включает оценку постов через DeepSeek API
DEEPSEEK_ENABLED=false
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_TIMEOUT=30

View File

@@ -1,12 +0,0 @@
apiVersion: 1
providers:
- name: 'Telegram Bot Dashboards'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true

View File

@@ -1 +0,0 @@
from . import server_monitor

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ from aiogram.types import Message
class ChatTypeFilter(BaseFilter): # [1]
def __init__(self, chat_type: Union[str, list]): # [2]
def __init__(self, chat_type: Union[str, list]): # [2]
self.chat_type = chat_type
async def __call__(self, message: Message) -> bool: # [3]

View File

@@ -1,37 +1,37 @@
from .admin_handlers import admin_router
from .dependencies import AdminAccessMiddleware, BotDB, Settings
from .services import AdminService, User, BannedUser
from .exceptions import (
AdminError,
AdminAccessDeniedError,
UserNotFoundError,
InvalidInputError,
UserAlreadyBannedError
AdminAccessDeniedError,
AdminError,
InvalidInputError,
UserAlreadyBannedError,
UserNotFoundError,
)
from .services import AdminService, BannedUser, User
from .utils import (
return_to_admin_menu,
handle_admin_error,
format_user_info,
escape_html,
format_ban_confirmation,
escape_html
format_user_info,
handle_admin_error,
return_to_admin_menu,
)
__all__ = [
'admin_router',
'AdminAccessMiddleware',
'BotDB',
'Settings',
'AdminService',
'User',
'BannedUser',
'AdminError',
'AdminAccessDeniedError',
'UserNotFoundError',
'InvalidInputError',
'UserAlreadyBannedError',
'return_to_admin_menu',
'handle_admin_error',
'format_user_info',
'format_ban_confirmation',
'escape_html'
]
"admin_router",
"AdminAccessMiddleware",
"BotDB",
"Settings",
"AdminService",
"User",
"BannedUser",
"AdminError",
"AdminAccessDeniedError",
"UserNotFoundError",
"InvalidInputError",
"UserAlreadyBannedError",
"return_to_admin_menu",
"handle_admin_error",
"format_user_info",
"format_ban_confirmation",
"escape_html",
]

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,8 +1,10 @@
from typing import Dict, Any
from typing import Any, Dict
try:
from typing import Annotated
except ImportError:
from typing_extensions import Annotated
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
@@ -13,29 +15,46 @@ from logs.custom_logger import logger
class AdminAccessMiddleware(BaseMiddleware):
"""Middleware для проверки административного доступа"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
if hasattr(event, 'from_user'):
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
if hasattr(event, "from_user"):
user_id = event.from_user.id
username = getattr(event.from_user, "username", "Unknown")
logger.info(
f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})"
)
# Получаем bot_db из data (внедренного DependenciesMiddleware)
bot_db = data.get('bot_db')
bot_db = data.get("bot_db")
if not bot_db:
# Fallback: получаем напрямую если middleware не сработала
bdf = get_global_instance()
bot_db = bdf.get_db()
if not check_access(user_id, bot_db):
if hasattr(event, 'answer'):
await event.answer('Доступ запрещен!')
is_admin_result = await check_access(user_id, bot_db)
logger.info(
f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}"
)
if not is_admin_result:
logger.warning(
f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})"
)
if hasattr(event, "answer"):
await event.answer("Доступ запрещен!")
return
try:
# Вызываем хендлер с data
return await handler(event, data)
except TypeError as e:
if "missing 1 required positional argument: 'data'" in str(e):
logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'")
logger.error(
f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'"
)
# Пытаемся вызвать хендлер без data (для совместимости с MagicData)
return await handler(event)
else:

View File

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

View File

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

View File

@@ -1,13 +1,24 @@
from typing import List, Optional
from datetime import datetime
from typing import List, Optional
from helper_bot.utils.helper_func import add_days_to_date, get_banned_users_buttons, get_banned_users_list
from helper_bot.handlers.admin.exceptions import UserAlreadyBannedError, InvalidInputError
from helper_bot.handlers.admin.exceptions import (
InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.utils.helper_func import (
add_days_to_date,
get_banned_users_buttons,
get_banned_users_list,
)
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
class User:
"""Модель пользователя"""
def __init__(self, user_id: int, username: str, full_name: str):
self.user_id = user_id
self.username = username
@@ -16,7 +27,10 @@ class User:
class BannedUser:
"""Модель заблокированного пользователя"""
def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]):
def __init__(
self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]
):
self.user_id = user_id
self.username = username
self.reason = reason
@@ -25,122 +39,158 @@ class BannedUser:
class AdminService:
"""Сервис для административных операций"""
def __init__(self, bot_db):
self.bot_db = bot_db
def get_last_users(self) -> List[User]:
@track_time("get_last_users", "admin_service")
@track_errors("admin_service", "get_last_users")
async def get_last_users(self) -> List[User]:
"""Получить список последних пользователей"""
try:
users_data = self.bot_db.get_last_users_from_db()
users_data = await self.bot_db.get_last_users(30)
return [
User(
user_id=user[1],
username='Неизвестно',
full_name=user[0]
)
User(user_id=user[1], username="Неизвестно", full_name=user[0])
for user in users_data
]
except Exception as e:
logger.error(f"Ошибка при получении списка последних пользователей: {e}")
raise
def get_banned_users(self) -> List[BannedUser]:
@track_time("get_banned_users", "admin_service")
@track_errors("admin_service", "get_banned_users")
async def get_banned_users(self) -> List[BannedUser]:
"""Получить список заблокированных пользователей"""
try:
banned_users_data = self.bot_db.get_banned_users_from_db()
return [
BannedUser(
user_id=user[1], # user_id
username=user[0], # user_name
reason=user[2], # message_for_user
unban_date=user[3] # date_to_unban
banned_users_data = await self.bot_db.get_banned_users_from_db()
banned_users = []
for user_data in banned_users_data:
user_id, reason, unban_date = user_data
# Получаем username и full_name из таблицы users
username = await self.bot_db.get_username(user_id)
full_name = await self.bot_db.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
banned_users.append(
BannedUser(
user_id=user_id,
username=user_name,
reason=reason,
unban_date=unban_date,
)
)
for user in banned_users_data
]
return banned_users
except Exception as e:
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении списка заблокированных пользователей: {e}"
)
raise
def get_user_by_username(self, username: str) -> Optional[User]:
@track_time("get_user_by_username", "admin_service")
@track_errors("admin_service", "get_user_by_username")
async def get_user_by_username(self, username: str) -> Optional[User]:
"""Получить пользователя по username"""
try:
user_id = self.bot_db.get_user_id_by_username(username)
user_id = await self.bot_db.get_user_id_by_username(username)
if not user_id:
return None
full_name = self.bot_db.get_full_name_by_id(user_id)
full_name = await self.bot_db.get_full_name_by_id(user_id)
return User(
user_id=user_id,
username=username,
full_name=full_name or 'Неизвестно'
user_id=user_id, username=username, full_name=full_name or "Неизвестно"
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
raise
def get_user_by_id(self, user_id: int) -> Optional[User]:
@track_time("get_user_by_id", "admin_service")
@track_errors("admin_service", "get_user_by_id")
async def get_user_by_id(self, user_id: int) -> Optional[User]:
"""Получить пользователя по ID"""
try:
user_info = self.bot_db.get_user_info_by_id(user_id)
user_info = await self.bot_db.get_user_by_id(user_id)
if not user_info:
return None
return User(
user_id=user_id,
username=user_info.get('username', 'Неизвестно'),
full_name=user_info.get('full_name', 'Неизвестно')
username=user_info.username or "Неизвестно",
full_name=user_info.full_name or "Неизвестно",
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
raise
def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int]) -> None:
@track_time("ban_user", "admin_service")
@track_errors("admin_service", "ban_user")
async def ban_user(
self,
user_id: int,
username: str,
reason: str,
ban_days: Optional[int],
ban_author_id: int,
) -> None:
"""Заблокировать пользователя"""
try:
# Проверяем, не заблокирован ли уже пользователь
if self.bot_db.check_user_in_blacklist(user_id):
if await self.bot_db.check_user_in_blacklist(user_id):
raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован")
# Рассчитываем дату разблокировки
date_to_unban = None
if ban_days is not None:
date_to_unban = add_days_to_date(ban_days)
# Сохраняем в БД
self.bot_db.set_user_blacklist(user_id, username, reason, date_to_unban)
logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней")
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
await self.bot_db.set_user_blacklist(
user_id, None, reason, date_to_unban, ban_author=ban_author_id
)
logger.info(
f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней"
)
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
raise
def unban_user(self, user_id: int) -> None:
@track_time("unban_user", "admin_service")
@track_errors("admin_service", "unban_user")
async def unban_user(self, user_id: int) -> None:
"""Разблокировать пользователя"""
try:
self.bot_db.delete_user_blacklist(user_id)
await self.bot_db.delete_user_blacklist(user_id)
logger.info(f"Пользователь {user_id} разблокирован")
except Exception as e:
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
raise
def validate_user_input(self, input_text: str) -> int:
@track_time("validate_user_input", "admin_service")
@track_errors("admin_service", "validate_user_input")
async def validate_user_input(self, input_text: str) -> int:
"""Валидация введенного ID пользователя"""
try:
user_id = int(input_text.strip())
if user_id <= 0:
raise InvalidInputError("ID пользователя должен быть положительным числом")
raise InvalidInputError(
"ID пользователя должен быть положительным числом"
)
return user_id
except ValueError:
raise InvalidInputError("ID пользователя должен быть числом")
def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
@track_time("get_banned_users_for_display", "admin_service")
@track_errors("admin_service", "get_banned_users_for_display")
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
"""Получить данные заблокированных пользователей для отображения"""
try:
message_text = get_banned_users_list(page, self.bot_db)
buttons_list = get_banned_users_buttons(self.bot_db)
message_text = await get_banned_users_list(page, self.bot_db)
buttons_list = await get_banned_users_buttons(self.bot_db)
return message_text, buttons_list
except Exception as e:
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении данных заблокированных пользователей: {e}"
)
raise

View File

@@ -1,10 +1,11 @@
import html
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from helper_bot.handlers.admin.exceptions import AdminError
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from logs.custom_logger import logger
@@ -13,29 +14,41 @@ def escape_html(text: str) -> str:
return html.escape(str(text)) if text else ""
async def return_to_admin_menu(message: types.Message, state: FSMContext,
additional_message: Optional[str] = None) -> None:
async def return_to_admin_menu(
message: types.Message, state: FSMContext, additional_message: Optional[str] = None
) -> None:
"""Универсальная функция для возврата в админ-меню"""
logger.info(
f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}"
)
await state.set_data({})
await state.set_state("ADMIN")
markup = get_reply_keyboard_admin()
if additional_message:
logger.info(
f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}"
)
await message.answer(additional_message)
await message.answer('Вернулись в меню', reply_markup=markup)
await message.answer("Вернулись в меню", reply_markup=markup)
logger.info(
f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню"
)
async def handle_admin_error(message: types.Message, error: Exception,
state: FSMContext, error_context: str = "") -> None:
async def handle_admin_error(
message: types.Message, error: Exception, state: FSMContext, error_context: str = ""
) -> None:
"""Централизованная обработка ошибок административных операций"""
logger.error(f"Ошибка в {error_context}: {error}")
if isinstance(error, AdminError):
await message.answer(f"Ошибка: {str(error)}")
else:
await message.answer("Произошла внутренняя ошибка. Попробуйте позже.")
await return_to_admin_menu(message, state)
@@ -43,19 +56,23 @@ def format_user_info(user_id: int, username: str, full_name: str) -> str:
"""Форматирование информации о пользователе для отображения"""
safe_username = escape_html(username)
safe_full_name = escape_html(full_name)
return (f"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {safe_full_name}")
return (
f"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {safe_full_name}"
)
def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str:
"""Форматирование подтверждения бана"""
safe_reason = escape_html(reason)
ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней"
return (f"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}")
return (
f"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}"
)

View File

@@ -1,24 +1,34 @@
from .callback_handlers import callback_router
from .services import PostPublishService, BanService
from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
from .constants import (
CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
CALLBACK_RETURN, CALLBACK_PAGE
CALLBACK_BAN,
CALLBACK_DECLINE,
CALLBACK_PAGE,
CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
from .services import BanService, PostPublishService
__all__ = [
'callback_router',
'PostPublishService',
'BanService',
'UserBlockedBotError',
'PostNotFoundError',
'UserNotFoundError',
'PublishError',
'BanError',
'CALLBACK_PUBLISH',
'CALLBACK_DECLINE',
'CALLBACK_BAN',
'CALLBACK_UNLOCK',
'CALLBACK_RETURN',
'CALLBACK_PAGE'
"callback_router",
"PostPublishService",
"BanService",
"UserBlockedBotError",
"PostNotFoundError",
"UserNotFoundError",
"PublishError",
"BanError",
"CALLBACK_PUBLISH",
"CALLBACK_DECLINE",
"CALLBACK_BAN",
"CALLBACK_UNLOCK",
"CALLBACK_RETURN",
"CALLBACK_PAGE",
]

View File

@@ -1,92 +1,124 @@
import html
import time
import traceback
from datetime import datetime
from aiogram import Router
from aiogram import F, Router
from aiogram.filters import MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from aiogram import F
from aiogram.filters import MagicData
from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \
create_keyboard_for_ban_reason
from helper_bot.utils.helper_func import get_banned_users_list, get_banned_users_buttons
from helper_bot.handlers.admin.utils import format_user_info
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
from helper_bot.handlers.voice.services import AudioFileService
from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance
from .dependency_factory import get_post_publish_service, get_ban_service
from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
from .constants import (
CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK,
CALLBACK_RETURN, CALLBACK_PAGE, MESSAGE_PUBLISHED, MESSAGE_DECLINED,
MESSAGE_USER_BANNED, MESSAGE_USER_UNLOCKED, MESSAGE_ERROR,
ERROR_BOT_BLOCKED
from helper_bot.utils.helper_func import get_banned_users_buttons, get_banned_users_list
# Local imports - metrics
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger
from .constants import (
CALLBACK_BAN,
CALLBACK_DECLINE,
CALLBACK_PAGE,
CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
ERROR_BOT_BLOCKED,
MESSAGE_DECLINED,
MESSAGE_ERROR,
MESSAGE_PUBLISHED,
MESSAGE_USER_BANNED,
MESSAGE_USER_UNLOCKED,
)
from .dependency_factory import get_ban_service, get_post_publish_service
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
callback_router = Router()
@callback_router.callback_query(F.data == CALLBACK_PUBLISH)
async def post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
async def post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info(
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})')
f"Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})"
)
try:
await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при публикации поста: {str(e)}')
logger.error(f"Ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}')
logger.error(f"Неожиданная ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
async def decline_post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
@track_time("decline_post_for_group", "callback_handlers")
@track_errors("callback_handlers", "decline_post_for_group")
async def decline_post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info(
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})')
f"Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})"
)
try:
await publish_service.decline_post(call)
await call.answer(text=MESSAGE_DECLINED, cache_time=3)
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при отклонении поста: {str(e)}')
logger.error(f"Ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}')
logger.error(f"Неожиданная ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_BAN)
async def ban_user_from_post(call: CallbackQuery):
@track_time("ban_user_from_post", "callback_handlers")
@track_errors("callback_handlers", "ban_user_from_post")
async def ban_user_from_post(call: CallbackQuery, **kwargs):
ban_service = get_ban_service()
# TODO: переделать на MagicData
try:
@@ -95,93 +127,272 @@ async def ban_user_from_post(call: CallbackQuery):
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (UserNotFoundError, BanError) as e:
logger.error(f'Ошибка при блокировке пользователя: {str(e)}')
logger.error(f"Ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}')
logger.error(f"Неожиданная ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data.contains(CALLBACK_BAN))
async def process_ban_user(call: CallbackQuery, state: FSMContext):
@track_time("process_ban_user", "callback_handlers")
@track_errors("callback_handlers", "process_ban_user")
async def process_ban_user(
call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[4:]
logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
logger.info(
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}"
)
# Проверяем, что user_id является валидным числом
try:
user_name = await ban_service.ban_user(user_id, "")
await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, date_to_unban=None)
markup = create_keyboard_for_ban_reason()
user_name_escaped = html.escape(str(user_name))
full_name_escaped = html.escape(str(call.message.from_user.full_name))
await call.message.answer(
text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup
user_id_int = int(user_id)
except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return
try:
# Получаем username пользователя
username = await ban_service.ban_user(str(user_id_int), "")
if not username:
raise UserNotFoundError(f"Пользователь с ID {user_id_int} не найден в базе")
# Получаем full_name пользователя из базы данных
full_name = await bot_db.get_full_name_by_id(user_id_int)
if not full_name:
full_name = "Неизвестно"
# Сохраняем данные в формате, совместимом с admin_handlers
await state.update_data(
target_user_id=user_id_int,
target_username=username,
target_full_name=full_name,
)
# Используем единый формат отображения информации о пользователе
user_info = format_user_info(user_id_int, username, full_name)
markup = create_keyboard_for_ban_reason()
await call.message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup,
)
await state.set_state("AWAIT_BAN_DETAILS")
logger.info(
f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}"
)
await state.set_state('BAN_2')
except UserNotFoundError:
markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN')
await call.message.answer(
text="Пользователь с таким ID не найден в базе", reply_markup=markup
)
await state.set_state("ADMIN")
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
async def process_unlock_user(call: CallbackQuery):
@track_time("process_unlock_user", "callback_handlers")
@track_errors("callback_handlers", "process_unlock_user")
async def process_unlock_user(call: CallbackQuery, **kwargs):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[7:]
# Проверяем, что user_id является валидным числом
try:
username = await ban_service.unlock_user(user_id)
await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True)
user_id_int = int(user_id)
except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return
try:
username = await ban_service.unlock_user(str(user_id_int))
await call.answer(f"{MESSAGE_USER_UNLOCKED} {username}", show_alert=True)
except UserNotFoundError:
await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3)
await call.answer(
text="Пользователь не найден в базе", show_alert=True, cache_time=3
)
except Exception as e:
logger.error(f'Ошибка при разблокировке пользователя: {str(e)}')
logger.error(f"Ошибка при разблокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_RETURN)
async def return_to_main_menu(call: CallbackQuery):
@track_time("return_to_main_menu", "callback_handlers")
@track_errors("callback_handlers", "return_to_main_menu")
async def return_to_main_menu(call: CallbackQuery, **kwargs):
await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
markup = get_reply_keyboard_admin()
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
await call.message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
@callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
async def change_page(
call: CallbackQuery,
bot_db: MagicData("bot_db")
):
page_number = int(call.data[5:])
@track_time("change_page", "callback_handlers")
@track_errors("callback_handlers", "change_page")
async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs):
try:
page_number = int(call.data[5:])
except ValueError:
logger.error(f"Некорректный номер страницы в callback: {call.data}")
await call.answer(
text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3
)
return
logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту':
list_users = bot_db.get_last_users_from_db()
keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, 'ban')
items_per_page = 9
if call.message.text == "Список пользователей которые последними обращались к боту":
list_users = await bot_db.get_last_users(30)
keyboard = create_keyboard_with_pagination(
page_number, len(list_users), list_users, "ban"
)
await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
else:
message_user = get_banned_users_list(int(page_number) * 7 - 7, bot_db)
offset = (page_number - 1) * items_per_page
message_user = await get_banned_users_list(offset, bot_db)
await call.bot.edit_message_text(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
text=message_user
text=message_user,
parse_mode="HTML",
)
buttons = await get_banned_users_buttons(bot_db)
keyboard = create_keyboard_with_pagination(
page_number, len(buttons), buttons, "unlock"
)
buttons = get_banned_users_buttons(bot_db)
keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock')
await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
@callback_router.callback_query(F.data == CALLBACK_SAVE)
@track_time("save_voice_message", "callback_handlers")
@track_errors("callback_handlers", "save_voice_message")
@track_file_operations("voice")
@db_query_time("save_voice_message", "audio_moderate", "mixed")
async def save_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs,
):
try:
logger.info(
f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}"
)
# Создаем сервис для работы с аудио файлами
audio_service = AudioFileService(bot_db)
# Получаем ID пользователя из базы
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(
call.message.message_id
)
logger.info(f"Получен user_id: {user_id}")
# Генерируем имя файла
file_name = await audio_service.generate_file_name(user_id)
logger.info(f"Сгенерировано имя файла: {file_name}")
# Собираем инфо о сообщении
time_UTC = int(time.time())
date_added = datetime.fromtimestamp(time_UTC)
# Получаем file_id из voice сообщения
file_id = call.message.voice.file_id if call.message.voice else ""
logger.info(f"Получен file_id: {file_id}")
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск
logger.info("Начинаем скачивание и сохранение файла на диск...")
await audio_service.download_and_save_audio(call.bot, call.message, file_name)
logger.info("Файл успешно скачан и сохранен на диск")
# Только после успешного сохранения файла - сохраняем в базу данных
logger.info("Начинаем сохранение информации в базу данных...")
await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
logger.info("Информация успешно сохранена в базу данных")
# Удаляем сообщение из предложки
logger.info("Удаляем сообщение из предложки...")
await call.bot.delete_message(
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
logger.info("Сообщение удалено из предложки")
# Удаляем запись из таблицы audio_moderate
logger.info("Удаляем запись из таблицы audio_moderate...")
await bot_db.delete_audio_moderate_record(call.message.message_id)
logger.info("Запись удалена из таблицы audio_moderate")
await call.answer(text="Сохранено!", cache_time=3)
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
except Exception as e:
logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Дополнительная информация для диагностики
try:
if "call" in locals() and call.message:
logger.error(f"Message ID: {call.message.message_id}")
logger.error(
f"User ID: {user_id if 'user_id' in locals() else 'не определен'}"
)
logger.error(
f"File name: {file_name if 'file_name' in locals() else 'не определен'}"
)
except:
pass
await call.answer(text="Ошибка при сохранении!", cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DELETE)
@track_time("delete_voice_message", "callback_handlers")
@track_errors("callback_handlers", "delete_voice_message")
@db_query_time("delete_voice_message", "audio_moderate", "delete")
async def delete_voice_message(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs,
):
try:
# Удаляем сообщение из предложки
await call.bot.delete_message(
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
# Удаляем запись из таблицы audio_moderate
await bot_db.delete_audio_moderate_record(call.message.message_id)
await call.answer(text="Удалено!", cache_time=3)
except Exception as e:
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text="Ошибка при удалении!", cache_time=3)

View File

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

View File

@@ -1,33 +1,29 @@
from typing import Callable
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.context import FSMContext
from helper_bot.utils.base_dependency_factory import get_global_instance
from .services import PostPublishService, BanService
from .services import BanService, PostPublishService
def get_post_publish_service() -> PostPublishService:
"""Фабрика для PostPublishService"""
bdf = get_global_instance()
bot = Bot(
token=bdf.settings['Telegram']['bot_token'],
default=DefaultBotProperties(parse_mode='HTML'),
timeout=30.0
)
db = bdf.get_db()
settings = bdf.settings
return PostPublishService(bot, db, settings)
s3_storage = bdf.get_s3_storage()
scoring_manager = bdf.get_scoring_manager()
return PostPublishService(None, db, settings, s3_storage, scoring_manager)
def get_ban_service() -> BanService:
"""Фабрика для BanService"""
bdf = get_global_instance()
bot = Bot(
token=bdf.settings['Telegram']['bot_token'],
default=DefaultBotProperties(parse_mode='HTML'),
timeout=30.0
)
db = bdf.get_db()
settings = bdf.settings
return BanService(bot, db, settings)
return BanService(None, db, settings)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,29 @@
"""Group handlers package for Telegram bot"""
# Local imports - main components
from .group_handlers import (
group_router,
create_group_handlers,
GroupHandlers
)
# Local imports - constants and utilities
from .constants import ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .exceptions import NoReplyToMessageError, UserNotFoundError
from .group_handlers import GroupHandlers, create_group_handlers, group_router
# Local imports - services
from .services import (
AdminReplyService,
DatabaseProtocol
)
# Local imports - constants and utilities
from .constants import (
FSM_STATES,
ERROR_MESSAGES
)
from .exceptions import (
NoReplyToMessageError,
UserNotFoundError
)
from .decorators import error_handler
from .services import AdminReplyService, DatabaseProtocol
__all__ = [
# Main components
'group_router',
'create_group_handlers',
'GroupHandlers',
"group_router",
"create_group_handlers",
"GroupHandlers",
# Services
'AdminReplyService',
'DatabaseProtocol',
"AdminReplyService",
"DatabaseProtocol",
# Constants
'FSM_STATES',
'ERROR_MESSAGES',
"FSM_STATES",
"ERROR_MESSAGES",
# Exceptions
'NoReplyToMessageError',
'UserNotFoundError',
"NoReplyToMessageError",
"UserNotFoundError",
# Utilities
'error_handler'
"error_handler",
]

View File

@@ -1,14 +1,12 @@
"""Constants for group handlers"""
from typing import Final, Dict
from typing import Dict, Final
# FSM States
FSM_STATES: Final[Dict[str, str]] = {
"CHAT": "CHAT"
}
FSM_STATES: Final[Dict[str, str]] = {"CHAT": "CHAT"}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение."
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение.",
}

View File

@@ -13,6 +13,7 @@ from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
@@ -20,17 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
except Exception:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

@@ -3,9 +3,11 @@
class NoReplyToMessageError(Exception):
"""Raised when admin tries to reply without selecting a message"""
pass
class UserNotFoundError(Exception):
"""Raised when user is not found in database for the given message_id"""
pass

View File

@@ -5,88 +5,89 @@ from aiogram import Router, types
from aiogram.fsm.context import FSMContext
# Local imports - filters
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
# Local imports - modular components
from .constants import FSM_STATES, ERROR_MESSAGES
from .services import AdminReplyService
from .decorators import error_handler
from .exceptions import UserNotFoundError
# Local imports - metrics
from helper_bot.utils.metrics import metrics, track_errors, track_time
# Local imports - utilities
from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors
)
# Local imports - modular components
from .constants import ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .exceptions import UserNotFoundError
from .services import AdminReplyService
class GroupHandlers:
"""Main handler class for group messages"""
def __init__(self, db, keyboard_markup: types.ReplyKeyboardMarkup):
def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup):
self.db = db
self.keyboard_markup = keyboard_markup
self.admin_reply_service = AdminReplyService(db)
# Create router
self.router = Router()
# Register handlers
self._register_handlers()
def _register_handlers(self):
"""Register all message handlers"""
self.router.message.register(
self.handle_message,
ChatTypeFilter(chat_type=["group", "supergroup"])
self.handle_message, ChatTypeFilter(chat_type=["group", "supergroup"])
)
@error_handler
async def handle_message(self, message: types.Message, state: FSMContext):
@track_errors("group_handlers", "handle_message")
@track_time("handle_message", "group_handlers")
async def handle_message(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle admin reply to user through group chat"""
logger.info(
f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) '
f"Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) "
f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"'
)
# Check if message is a reply
if not message.reply_to_message:
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
logger.warning(
f'В группе {message.chat.title} (ID: {message.chat.id}) '
f'админ не выделил сообщение для ответа.'
f"В группе {message.chat.title} (ID: {message.chat.id}) "
f"админ не выделил сообщение для ответа."
)
return
message_id = message.reply_to_message.message_id
reply_text = message.text
try:
# Get user ID for reply
chat_id = self.admin_reply_service.get_user_id_for_reply(message_id)
chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id)
# Send reply to user
await self.admin_reply_service.send_reply_to_user(
chat_id, message, reply_text, self.keyboard_markup
)
# Set state
await state.set_state(FSM_STATES["CHAT"])
except UserNotFoundError:
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
logger.error(
f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} '
f'в группе {message.chat.title} (ID сообщения: {message.message_id})'
f"Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} "
f"в группе {message.chat.title} (ID сообщения: {message.message_id})"
)
# Factory function to create handlers with dependencies
def create_group_handlers(db, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers:
def create_group_handlers(
db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup
) -> GroupHandlers:
"""Create group handlers instance with dependencies"""
return GroupHandlers(db, keyboard_markup)
@@ -94,20 +95,23 @@ def create_group_handlers(db, keyboard_markup: types.ReplyKeyboardMarkup) -> Gro
# Legacy router for backward compatibility
group_router = Router()
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
global group_router
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
# TODO: поменять архитектуру и подключить правильный BotDB
db = bdf.get_db()
keyboard_markup = get_reply_keyboard_leave_chat()
handlers = create_group_handlers(db, keyboard_markup)
group_router = handlers.router
# Initialize legacy router
init_legacy_router()

View File

@@ -1,64 +1,69 @@
"""Service classes for group handlers"""
# Standard library imports
from typing import Protocol, Optional
from typing import Optional, Protocol
# Third-party imports
from aiogram import types
# Local imports
from helper_bot.utils.helper_func import send_text_message
from .exceptions import NoReplyToMessageError, UserNotFoundError
from logs.custom_logger import logger
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors,
db_query_time
)
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
from .exceptions import NoReplyToMessageError, UserNotFoundError
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
): ...
class AdminReplyService:
"""Service for admin reply operations"""
def __init__(self, db: DatabaseProtocol) -> None:
self.db = db
def get_user_id_for_reply(self, message_id: int) -> int:
@track_time("get_user_id_for_reply", "admin_reply_service")
@track_errors("admin_reply_service", "get_user_id_for_reply")
@db_query_time("get_user_id_for_reply", "users", "select")
async def get_user_id_for_reply(self, message_id: int) -> int:
"""
Get user ID for reply by message ID.
Args:
message_id: ID of the message to reply to
Returns:
User ID for the reply
Raises:
UserNotFoundError: If user is not found in database
"""
user_id = self.db.get_user_by_message_id(message_id)
user_id = await self.db.get_user_by_message_id(message_id)
if user_id is None:
raise UserNotFoundError(f"User not found for message_id: {message_id}")
return user_id
@track_time("send_reply_to_user", "admin_reply_service")
@track_errors("admin_reply_service", "send_reply_to_user")
async def send_reply_to_user(
self,
chat_id: int,
message: types.Message,
reply_text: str,
markup: types.ReplyKeyboardMarkup
self,
chat_id: int,
message: types.Message,
reply_text: str,
markup: types.ReplyKeyboardMarkup,
) -> None:
"""
Send reply to user.
Args:
chat_id: User's chat ID
message: Original message from admin

View File

@@ -1,45 +1,28 @@
"""Private handlers package for Telegram bot"""
# Local imports - main components
from .private_handlers import (
private_router,
create_private_handlers,
PrivateHandlers
)
# Local imports - constants and utilities
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .private_handlers import PrivateHandlers, create_private_handlers, private_router
# Local imports - services
from .services import (
BotSettings,
UserService,
PostService,
StickerService
)
# Local imports - constants and utilities
from .constants import (
FSM_STATES,
BUTTON_TEXTS,
ERROR_MESSAGES
)
from .decorators import error_handler
from .services import BotSettings, PostService, StickerService, UserService
__all__ = [
# Main components
'private_router',
'create_private_handlers',
'PrivateHandlers',
"private_router",
"create_private_handlers",
"PrivateHandlers",
# Services
'BotSettings',
'UserService',
'PostService',
'StickerService',
"BotSettings",
"UserService",
"PostService",
"StickerService",
# Constants
'FSM_STATES',
'BUTTON_TEXTS',
'ERROR_MESSAGES',
"FSM_STATES",
"BUTTON_TEXTS",
"ERROR_MESSAGES",
# Utilities
'error_handler'
"error_handler",
]

View File

@@ -1,13 +1,13 @@
"""Constants for private handlers"""
from typing import Final, Dict
from typing import Dict, Final
# FSM States
FSM_STATES: Final[Dict[str, str]] = {
"START": "START",
"SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT",
"CHAT": "CHAT"
"CHAT": "CHAT",
}
# Button texts
@@ -17,15 +17,27 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
"LEAVE_CHAT": "Выйти из чата",
"RETURN_TO_BOT": "Вернуться в бота",
"WANT_STICKERS": "🤪Хочу стикеры",
"CONNECT_ADMIN": "📩Связаться с админами"
"CONNECT_ADMIN": "📩Связаться с админами",
"VOICE_BOT": "🎤Голосовой бот",
}
# Button to command mapping for metrics
BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"📢Предложить свой пост": "suggest_post",
"👋🏼Сказать пока!": "say_goodbye",
"Выйти из чата": "leave_chat",
"Вернуться в бота": "return_to_bot",
"🤪Хочу стикеры": "want_stickers",
"📩Связаться с админами": "connect_admin",
"🎤Голосовой бот": "voice_bot",
}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. '
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'
'Мы добавим его к обработке если необходимо'
"Я пока не умею работать с таким сообщением. "
"Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n"
"Мы добавим его к обработке если необходимо"
),
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk"
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk",
}

View File

@@ -13,6 +13,7 @@ from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
@@ -20,17 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
except Exception:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

@@ -5,36 +5,39 @@ import asyncio
from datetime import datetime
# Third-party imports
from aiogram import types, Router, F
from aiogram import F, Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
# Local imports - filters and middlewares
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
# Local imports - utilities
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import (
get_first_name,
update_user_info,
check_user_emoji
check_user_emoji,
get_first_name,
update_user_info,
)
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors
)
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
# Local imports - modular components
from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
from .services import BotSettings, UserService, PostService, StickerService
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .services import (
AutoModerationService,
BotSettings,
PostService,
StickerService,
UserService,
)
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
sleep = asyncio.sleep
@@ -42,156 +45,297 @@ sleep = asyncio.sleep
class PrivateHandlers:
"""Main handler class for private messages"""
def __init__(self, db, settings: BotSettings):
def __init__(
self,
db: AsyncBotDB,
settings: BotSettings,
s3_storage=None,
scoring_manager=None,
):
self.db = db
self.settings = settings
self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings)
self.auto_moderation_service = AutoModerationService(
db, settings, scoring_manager, s3_storage
)
self.post_service = PostService(
db, settings, s3_storage, scoring_manager, self.auto_moderation_service
)
self.sticker_service = StickerService(settings)
# Create router
self.router = Router()
self.router.message.middleware(AlbumMiddleware())
self.router.message.middleware(AlbumMiddleware(latency=5.0))
self.router.message.middleware(BlacklistMiddleware())
# Register handlers
self._register_handlers()
def _register_handlers(self):
"""Register all message handlers"""
# Command handlers
self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji"))
self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart"))
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), Command("start"))
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["RETURN_TO_BOT"])
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command("emoji"),
)
self.router.message.register(
self.handle_restart_message,
ChatTypeFilter(chat_type=["private"]),
Command("restart"),
)
self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
Command("start"),
)
self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["RETURN_TO_BOT"],
)
# Button handlers
self.router.message.register(self.suggest_post, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SUGGEST_POST"])
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SAY_GOODBYE"])
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["LEAVE_CHAT"])
self.router.message.register(self.stickers, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["WANT_STICKERS"])
self.router.message.register(self.connect_with_admin, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["CONNECT_ADMIN"])
self.router.message.register(
self.suggest_post,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SUGGEST_POST"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SAY_GOODBYE"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["LEAVE_CHAT"],
)
self.router.message.register(
self.stickers,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["WANT_STICKERS"],
)
self.router.message.register(
self.connect_with_admin,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["CONNECT_ADMIN"],
)
# State handlers
self.router.message.register(self.suggest_router, StateFilter(FSM_STATES["SUGGEST"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["PRE_CHAT"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["CHAT"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(
self.suggest_router,
StateFilter(FSM_STATES["SUGGEST"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["PRE_CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
@error_handler
async def handle_emoji_message(self, message: types.Message, state: FSMContext, **kwargs):
@track_errors("private_handlers", "handle_emoji_message")
@track_time("handle_emoji_message", "private_handlers")
async def handle_emoji_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle emoji command"""
await self.user_service.log_user_message(message)
user_emoji = check_user_emoji(message)
user_emoji = await check_user_emoji(message)
await state.set_state(FSM_STATES["START"])
if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
@error_handler
async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs):
@track_errors("private_handlers", "handle_restart_message")
@track_time("handle_restart_message", "private_handlers")
async def handle_restart_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle restart command"""
markup = get_reply_keyboard(self.db, message.from_user.id)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["START"])
await update_user_info('love', message)
check_user_emoji(message)
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML')
await update_user_info("love", message)
await check_user_emoji(message)
await message.answer("Я перезапущен!", reply_markup=markup, parse_mode="HTML")
@error_handler
async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs):
@track_errors("private_handlers", "handle_start_message")
@track_time("handle_start_message", "private_handlers")
async def handle_start_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle start command and return to bot button with metrics tracking"""
# User service operations with metrics
await self.user_service.log_user_message(message)
await self.user_service.ensure_user_exists(message)
await state.set_state(FSM_STATES["START"])
# Send sticker with metrics
await self.sticker_service.send_random_hello_sticker(message)
# Send welcome message with metrics
markup = get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML')
markup = await get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), "HELLO_MESSAGE")
await message.answer(hello_message, reply_markup=markup, parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "suggest_post")
@track_time("suggest_post", "private_handlers")
async def suggest_post(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle suggest post button"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["SUGGEST"])
markup = types.ReplyKeyboardRemove()
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS')
suggest_news = messages.get_message(get_first_name(message), "SUGGEST_NEWS")
await message.answer(suggest_news, reply_markup=markup)
@error_handler
@track_errors("private_handlers", "end_message")
@track_time("end_message", "private_handlers")
async def end_message(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle goodbye button"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message)
# Send sticker
await self.sticker_service.send_random_goodbye_sticker(message)
# Send goodbye message
markup = types.ReplyKeyboardRemove()
bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE')
bye_message = messages.get_message(get_first_name(message), "BYE_MESSAGE")
await message.answer(bye_message, reply_markup=markup)
await state.set_state(FSM_STATES["START"])
@error_handler
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
"""Handle post submission in suggest state"""
# Post service operations with metrics
await self.post_service.process_post(message, album)
# Send success message and return to start state
markup_for_user = get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
@track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers")
async def suggest_router(
self, message: types.Message, state: FSMContext, album: list = None, **kwargs
):
"""Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне"""
# Сразу отвечаем пользователю
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(
get_first_name(message), "SUCCESS_SEND_MESSAGE"
)
await message.answer(success_send_message, reply_markup=markup_for_user)
await state.set_state(FSM_STATES["START"])
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
album_getter = kwargs.get("album_getter")
# В фоне обрабатываем пост
async def process_post_background():
try:
# Обновляем активность пользователя
await self.user_service.update_user_activity(message.from_user.id)
# Логируем сообщение (только для одиночных сообщений, не медиагрупп)
if message.media_group_id is None:
await self.user_service.log_user_message(message)
# Для медиагрупп ждем полную медиагруппу
if album_getter and message.media_group_id:
full_album = await album_getter.get_album(timeout=10.0)
if full_album:
await self.post_service.process_post(message, full_album)
else:
# Обычное сообщение или медиагруппа уже собрана
await self.post_service.process_post(message, album)
except Exception as e:
from logs.custom_logger import logger
logger.error(f"Ошибка при фоновой обработке поста: {e}")
asyncio.create_task(process_post_background())
@error_handler
@track_errors("private_handlers", "stickers")
@track_time("stickers", "private_handlers")
@db_query_time("stickers", "stickers", "update")
async def stickers(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle stickers request"""
# User service operations with metrics
markup = get_reply_keyboard(self.db, message.from_user.id)
self.db.update_info_about_stickers(user_id=message.from_user.id)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.db.update_stickers_info(message.from_user.id)
await self.user_service.log_user_message(message)
await message.answer(
text=ERROR_MESSAGES["STICKERS_LINK"],
reply_markup=markup
)
await message.answer(text=ERROR_MESSAGES["STICKERS_LINK"], reply_markup=markup)
await state.set_state(FSM_STATES["START"])
@error_handler
async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs):
@track_errors("private_handlers", "connect_with_admin")
@track_time("connect_with_admin", "private_handlers")
async def connect_with_admin(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle connect with admin button"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN')
admin_message = messages.get_message(
get_first_name(message), "CONNECT_WITH_ADMIN"
)
await message.answer(admin_message, parse_mode="html")
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["PRE_CHAT"])
@error_handler
async def resend_message_in_group_for_message(self, message: types.Message, state: FSMContext, **kwargs):
@track_errors("private_handlers", "resend_message_in_group_for_message")
@track_time("resend_message_in_group_for_message", "private_handlers")
@db_query_time("resend_message_in_group_for_message", "messages", "insert")
async def resend_message_in_group_for_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle messages in admin chat states"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
await message.forward(chat_id=self.settings.group_for_message)
# Формируем обогащённое сообщение для админов
user_id = message.from_user.id
full_name = message.from_user.full_name
username = message.from_user.username
message_text = message.text or ""
enriched_message = await self.user_service.format_user_message_for_admins(
user_id=user_id,
full_name=full_name,
username=username,
message_text=message_text,
)
# Отправляем обогащённое сообщение вместо forward
sent_message = await message.bot.send_message(
chat_id=self.settings.group_for_message,
text=enriched_message,
parse_mode="HTML",
)
current_date = datetime.now()
date = current_date.strftime("%Y-%m-%d %H:%M:%S")
self.db.add_new_message_in_db(message.text, message.from_user.id, message.message_id + 1, date)
question = messages.get_message(get_first_name(message), 'QUESTION')
date = int(current_date.timestamp())
# Сохраняем message_id из результата send_message
await self.db.add_message(
message.text, message.from_user.id, sent_message.message_id, date
)
question = messages.get_message(get_first_name(message), "QUESTION")
user_state = await state.get_state()
if user_state == FSM_STATES["PRE_CHAT"]:
markup = get_reply_keyboard(self.db, message.from_user.id)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(question, reply_markup=markup)
await state.set_state(FSM_STATES["START"])
elif user_state == FSM_STATES["CHAT"]:
@@ -200,39 +344,52 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies
def create_private_handlers(db, settings: BotSettings) -> PrivateHandlers:
def create_private_handlers(
db: AsyncBotDB, settings: BotSettings, s3_storage=None, scoring_manager=None
) -> PrivateHandlers:
"""Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings)
return PrivateHandlers(db, settings, s3_storage, scoring_manager)
# Legacy router for backward compatibility
private_router = Router()
# Флаг инициализации для защиты от повторного вызова
_legacy_router_initialized = False
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
global private_router
global private_router, _legacy_router_initialized
if _legacy_router_initialized:
return
from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
settings = BotSettings(
group_for_posts=bdf.settings['Telegram']['group_for_posts'],
group_for_message=bdf.settings['Telegram']['group_for_message'],
main_public=bdf.settings['Telegram']['main_public'],
group_for_logs=bdf.settings['Telegram']['group_for_logs'],
important_logs=bdf.settings['Telegram']['important_logs'],
preview_link=bdf.settings['Telegram']['preview_link'],
logs=bdf.settings['Settings']['logs'],
test=bdf.settings['Settings']['test']
group_for_posts=bdf.settings["Telegram"]["group_for_posts"],
group_for_message=bdf.settings["Telegram"]["group_for_message"],
main_public=bdf.settings["Telegram"]["main_public"],
group_for_logs=bdf.settings["Telegram"]["group_for_logs"],
important_logs=bdf.settings["Telegram"]["important_logs"],
preview_link=bdf.settings["Telegram"]["preview_link"],
logs=bdf.settings["Settings"]["logs"],
test=bdf.settings["Settings"]["test"],
)
db = bdf.get_db()
handlers = create_private_handlers(db, settings)
s3_storage = bdf.get_s3_storage()
scoring_manager = bdf.get_scoring_manager()
handlers = create_private_handlers(db, settings, s3_storage, scoring_manager)
# Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture
private_router = handlers.router
_legacy_router_initialized = True
# Initialize legacy router
init_legacy_router()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import html
import time
from datetime import datetime
from typing import Optional
from helper_bot.handlers.voice.exceptions import DatabaseError
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
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]
@track_time("get_last_message_text", "voice_utils")
@track_errors("voice_utils", "get_last_message_text")
@db_query_time("get_last_message_text", "voice", "select")
async def get_last_message_text(bot_db) -> Optional[str]:
"""Получить текст сообщения о времени последней записи"""
try:
date_from_db = await bot_db.last_date_audio()
if date_from_db is None:
return None
# Преобразуем UNIX timestamp в строку для format_time_ago
date_string = datetime.fromtimestamp(date_from_db).strftime("%Y-%m-%d %H:%M:%S")
return format_time_ago(date_string)
except Exception as e:
logger.error(f"Не удалось получить дату последнего сообщения - {e}")
return None
async def validate_voice_message(message) -> bool:
"""Проверить валидность голосового сообщения"""
return message.content_type == "voice"
@track_time("get_user_emoji_safe", "voice_utils")
@track_errors("voice_utils", "get_user_emoji_safe")
@db_query_time("get_user_emoji_safe", "voice", "select")
async def get_user_emoji_safe(bot_db, user_id: int) -> str:
"""Безопасно получить эмодзи пользователя"""
try:
user_emoji = await bot_db.get_user_emoji(user_id)
return (
user_emoji
if user_emoji and user_emoji != "Смайл еще не определен"
else "😊"
)
except Exception as e:
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
return "😊"

View File

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

View File

@@ -1 +1 @@
from .keyboards import get_reply_keyboard_for_post, get_reply_keyboard
from .keyboards import get_reply_keyboard, get_reply_keyboard_for_post

View File

@@ -1,36 +1,28 @@
from aiogram import types
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors
)
from helper_bot.utils.metrics import track_errors, track_time
def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(
text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(
text="Отклонить", callback_data="decline")
)
builder.row(types.InlineKeyboardButton(
text="👮‍♂️ Забанить", callback_data="ban")
builder.row(
types.InlineKeyboardButton(text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(text="Отклонить", callback_data="decline"),
)
builder.row(types.InlineKeyboardButton(text="👮‍♂️ Забанить", callback_data="ban"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@track_time("get_reply_keyboard", "keyboard_service")
@track_errors("keyboard_service", "get_reply_keyboard")
def get_reply_keyboard(BotDB, user_id):
async def get_reply_keyboard(db, user_id):
builder = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
builder.row(types.KeyboardButton(text="📩Связаться с админами"))
builder.row(types.KeyboardButton(text=" 🎤Голосовой бот"))
builder.row(types.KeyboardButton(text="👋🏼Сказать пока!"))
if not BotDB.get_info_about_stickers(user_id=user_id):
if not await db.get_stickers_info(user_id):
builder.row(types.KeyboardButton(text="🤪Хочу стикеры"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@@ -48,17 +40,75 @@ def get_reply_keyboard_admin():
builder.row(
types.KeyboardButton(text="Бан (Список)"),
types.KeyboardButton(text="Бан по нику"),
types.KeyboardButton(text="Бан по ID")
types.KeyboardButton(text="Бан по ID"),
)
builder.row(
types.KeyboardButton(text="Разбан (список)"),
types.KeyboardButton(text="Вернуться в бота")
types.KeyboardButton(text="📊 ML Статистика"),
)
builder.row(types.KeyboardButton(text="⚙️ Авто-модерация"))
builder.row(types.KeyboardButton(text="Вернуться в бота"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str):
def get_auto_moderation_keyboard(settings: dict) -> types.InlineKeyboardMarkup:
"""
Создает inline клавиатуру для управления авто-модерацией.
Args:
settings: Словарь с текущими настройками авто-модерации
Returns:
InlineKeyboardMarkup с кнопками управления
"""
builder = InlineKeyboardBuilder()
auto_publish = settings.get("auto_publish_enabled", False)
auto_decline = settings.get("auto_decline_enabled", False)
publish_threshold = settings.get("auto_publish_threshold", 0.8)
decline_threshold = settings.get("auto_decline_threshold", 0.4)
publish_status = "" if auto_publish else ""
decline_status = "" if auto_decline else ""
builder.row(
types.InlineKeyboardButton(
text=f"{publish_status} Авто-публикация (≥{publish_threshold})",
callback_data="auto_mod_toggle_publish",
)
)
builder.row(
types.InlineKeyboardButton(
text=f"{decline_status} Авто-отклонение (≤{decline_threshold})",
callback_data="auto_mod_toggle_decline",
)
)
builder.row(
types.InlineKeyboardButton(
text="📈 Изменить порог публикации",
callback_data="auto_mod_threshold_publish",
),
types.InlineKeyboardButton(
text="📉 Изменить порог отклонения",
callback_data="auto_mod_threshold_decline",
),
)
builder.row(
types.InlineKeyboardButton(
text="🔄 Обновить",
callback_data="auto_mod_refresh",
)
)
return builder.as_markup()
@track_time("create_keyboard_with_pagination", "keyboard_service")
@track_errors("keyboard_service", "create_keyboard_with_pagination")
def create_keyboard_with_pagination(
page: int, total_items: int, array_items: list, callback: str
):
"""
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
@@ -71,74 +121,79 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
Returns:
InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
"""
# Проверяем валидность входных данных
if page < 1:
page = 1
if not array_items:
# Если нет элементов, возвращаем только кнопку "Назад"
keyboard = InlineKeyboardBuilder()
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
home_button = types.InlineKeyboardButton(
text="🏠 Назад", callback_data="return"
)
keyboard.row(home_button)
return keyboard.as_markup()
# Определяем общее количество страниц
items_per_page = 9
total_pages = (total_items + items_per_page - 1) // items_per_page
# Ограничиваем страницу максимальным значением
if page > total_pages:
page = total_pages
# Создаем билдер для клавиатуры
keyboard = InlineKeyboardBuilder()
# Вычисляем стартовый номер для текущей страницы
start_index = (page - 1) * items_per_page
# Кнопки с элементами текущей страницы
end_index = min(start_index + items_per_page, len(array_items))
current_row = []
for i in range(start_index, end_index):
current_row.append(types.InlineKeyboardButton(
text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}"
))
current_row.append(
types.InlineKeyboardButton(
text=f"{array_items[i][0]}",
callback_data=f"{callback}_{array_items[i][1]}",
)
)
# Когда набирается 3 кнопки, добавляем ряд
if len(current_row) == 3:
keyboard.row(*current_row)
current_row = []
# Добавляем оставшиеся кнопки, если они есть
if current_row:
keyboard.row(*current_row)
# Создаем кнопки навигации только если нужно
navigation_buttons = []
# Кнопка "Предыдущая" - показываем только если не первая страница
if page > 1:
prev_button = types.InlineKeyboardButton(
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
)
navigation_buttons.append(prev_button)
# Кнопка "Следующая" - показываем только если не последняя страница
if page < total_pages:
next_button = types.InlineKeyboardButton(
text="➡️ Следующая", callback_data=f"page_{page + 1}"
)
navigation_buttons.append(next_button)
# Добавляем кнопки навигации, если они есть
if navigation_buttons:
keyboard.row(*navigation_buttons)
# Кнопка "Назад"
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
keyboard.row(home_button)
return keyboard.as_markup()
@@ -147,7 +202,11 @@ def create_keyboard_for_ban_reason():
builder.add(types.KeyboardButton(text="Спам"))
builder.add(types.KeyboardButton(text="Заебал стикерами"))
builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 "))
builder.row(types.KeyboardButton(text="Тема с лагерями: https://vk.com/topic-75343895_50049913"))
builder.row(
types.KeyboardButton(
text="Тема с лагерями: https://vk.com/topic-75343895_50049913"
)
)
builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@@ -170,3 +229,29 @@ def create_keyboard_for_approve_ban():
builder.add(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
def get_main_keyboard():
builder = ReplyKeyboardBuilder()
# Первая строка: Высказаться и послушать
builder.row(
types.KeyboardButton(text="🎤Высказаться"),
types.KeyboardButton(text="🎧Послушать"),
)
# Вторая строка: сбросить прослушивания и узнать эмодзи
builder.row(
types.KeyboardButton(text="🔄Сбросить прослушивания"),
types.KeyboardButton(text="😊Узнать эмодзи"),
)
# Третья строка: Вернуться в меню
builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True)
return markup
def get_reply_keyboard_for_voice():
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(text="Сохранить", callback_data="save"))
builder.row(types.InlineKeyboardButton(text="Удалить", callback_data="delete"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from typing import Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
@@ -8,24 +9,28 @@ from logs.custom_logger import logger
class DependenciesMiddleware(BaseMiddleware):
"""Универсальная middleware для внедрения зависимостей во все хендлеры"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
try:
# Получаем глобальные зависимости
bdf = get_global_instance()
# Внедряем зависимости в data для MagicData
if 'bot_db' not in data:
data['bot_db'] = bdf.get_db()
if 'settings' not in data:
data['settings'] = bdf.settings
data['bot'] = data.get('bot')
data['dp'] = data.get('dp')
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
if "bot_db" not in data:
data["bot_db"] = bdf.get_db()
if "settings" not in data:
data["settings"] = bdf.settings
data["bot"] = data.get("bot")
data["dp"] = data.get("dp")
logger.debug(
f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}"
)
except Exception as e:
logger.error(f"Ошибка в DependenciesMiddleware: {e}")
# Не прерываем выполнение, продолжаем без зависимостей
return await handler(event, data)

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ class BulkTextMiddleware(BaseMiddleware):
self.latency = latency
self.texts = defaultdict(list)
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Main middleware logic.
@@ -37,10 +36,9 @@ class BulkTextMiddleware(BaseMiddleware):
# # Sort the album messages by message_id and add to data
msg_texts = self.texts[key]
msg_texts.sort(key=lambda x: x.message_id)
data["texts"] = ''.join([msg.text for msg in msg_texts])
data["texts"] = "".join([msg.text for msg in msg_texts])
#
# Remove the media group from tracking to free up memory
del self.texts[key]
# # Call the original event handler
return await handler(event, data)

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

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

View File

@@ -1,623 +0,0 @@
import asyncio
import os
import psutil
import time
import platform
from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple
import logging
logger = logging.getLogger(__name__)
class ServerMonitor:
def __init__(self, bot, group_for_logs: str, important_logs: str):
self.bot = bot
self.group_for_logs = group_for_logs
self.important_logs = important_logs
# Определяем ОС
self.os_type = self._detect_os()
logger.info(f"Обнаружена ОС: {self.os_type}")
# Пороговые значения для алертов
self.threshold = 80.0
self.recovery_threshold = 75.0
# Состояние алертов для предотвращения спама
self.alert_states = {
'cpu': False,
'ram': False,
'disk': False
}
# PID файлы для отслеживания процессов
self.pid_files = {
'voice_bot': 'voice_bot.pid',
'helper_bot': 'helper_bot.pid'
}
# Время последней отправки статуса
self.last_status_time = None
# Для расчета скорости диска
self.last_disk_io = None
self.last_disk_io_time = None
# Время запуска бота для расчета uptime
self.bot_start_time = time.time()
def _detect_os(self) -> str:
"""Определение типа операционной системы"""
system = platform.system().lower()
if system == "darwin":
return "macos"
elif system == "linux":
return "ubuntu"
else:
return "unknown"
def _get_disk_path(self) -> str:
"""Получение пути к диску в зависимости от ОС"""
if self.os_type == "macos":
return "/"
elif self.os_type == "ubuntu":
return "/"
else:
return "/"
def _get_disk_usage(self) -> Optional[object]:
"""Получение информации о диске с учетом ОС"""
try:
if self.os_type == "macos":
# На macOS используем diskutil для получения реального использования диска
return self._get_macos_disk_usage()
else:
disk_path = self._get_disk_path()
return psutil.disk_usage(disk_path)
except Exception as e:
logger.error(f"Ошибка при получении информации о диске: {e}")
return None
def _get_macos_disk_usage(self) -> Optional[object]:
"""Получение информации о диске на macOS через diskutil"""
try:
import subprocess
import re
# Получаем информацию о диске через diskutil
result = subprocess.run(['diskutil', 'info', '/'], capture_output=True, text=True)
if result.returncode != 0:
# Fallback к psutil
return psutil.disk_usage('/')
output = result.stdout
# Извлекаем размеры из вывода diskutil
total_match = re.search(r'Container Total Space:\s+(\d+\.\d+)\s+GB', output)
free_match = re.search(r'Container Free Space:\s+(\d+\.\d+)\s+GB', output)
if total_match and free_match:
total_gb = float(total_match.group(1))
free_gb = float(free_match.group(1))
used_gb = total_gb - free_gb
# Создаем объект, похожий на результат psutil.disk_usage
class DiskUsage:
def __init__(self, total, used, free):
self.total = total * (1024**3) # Конвертируем в байты
self.used = used * (1024**3)
self.free = free * (1024**3)
return DiskUsage(total_gb, used_gb, free_gb)
else:
# Fallback к psutil
return psutil.disk_usage('/')
except Exception as e:
logger.error(f"Ошибка при получении информации о диске macOS: {e}")
# Fallback к psutil
return psutil.disk_usage('/')
def _get_disk_io_counters(self):
"""Получение статистики диска с учетом ОС"""
try:
if self.os_type == "macos":
# На macOS может быть несколько дисков, берем основной
return psutil.disk_io_counters(perdisk=False)
elif self.os_type == "ubuntu":
# На Ubuntu обычно один диск
return psutil.disk_io_counters(perdisk=False)
else:
return psutil.disk_io_counters()
except Exception as e:
logger.error(f"Ошибка при получении статистики диска: {e}")
return None
def _get_system_uptime(self) -> float:
"""Получение uptime системы с учетом ОС"""
try:
if self.os_type == "macos":
# На macOS используем boot_time
boot_time = psutil.boot_time()
return time.time() - boot_time
elif self.os_type == "ubuntu":
# На Ubuntu также используем boot_time
boot_time = psutil.boot_time()
return time.time() - boot_time
else:
boot_time = psutil.boot_time()
return time.time() - boot_time
except Exception as e:
logger.error(f"Ошибка при получении uptime системы: {e}")
return 0.0
def get_bot_uptime(self) -> str:
"""Получение uptime бота"""
uptime_seconds = time.time() - self.bot_start_time
return self._format_uptime(uptime_seconds)
def get_system_info(self) -> Dict:
"""Получение информации о системе"""
try:
# CPU
cpu_percent = psutil.cpu_percent(interval=1)
load_avg = psutil.getloadavg()
cpu_count = psutil.cpu_count()
# Память
memory = psutil.virtual_memory()
swap = psutil.swap_memory()
# Используем единый расчет для всех ОС: used / total для получения процента занятой памяти
# Это обеспечивает консистентность между macOS и Ubuntu
ram_percent = (memory.used / memory.total) * 100
# Диск
disk = self._get_disk_usage()
disk_io = self._get_disk_io_counters()
if disk is None:
logger.error("Не удалось получить информацию о диске")
return {}
# Расчет скорости диска
disk_read_speed, disk_write_speed = self._calculate_disk_speed(disk_io)
# Система
system_uptime = self._get_system_uptime()
# Получаем имя хоста в зависимости от ОС
if self.os_type == "macos":
hostname = os.uname().nodename
elif self.os_type == "ubuntu":
hostname = os.uname().nodename
else:
hostname = "unknown"
return {
'cpu_percent': cpu_percent,
'load_avg_1m': round(load_avg[0], 2),
'load_avg_5m': round(load_avg[1], 2),
'load_avg_15m': round(load_avg[2], 2),
'cpu_count': cpu_count,
'ram_used': round(memory.used / (1024**3), 2),
'ram_total': round(memory.total / (1024**3), 2),
'ram_percent': round(ram_percent, 1), # Исправленный процент занятой памяти
'swap_used': round(swap.used / (1024**3), 2),
'swap_total': round(swap.total / (1024**3), 2),
'swap_percent': swap.percent,
'disk_used': round(disk.used / (1024**3), 2),
'disk_total': round(disk.total / (1024**3), 2),
'disk_percent': round((disk.used / disk.total) * 100, 1),
'disk_free': round(disk.free / (1024**3), 2),
'disk_read_speed': disk_read_speed,
'disk_write_speed': disk_write_speed,
'disk_io_percent': self._calculate_disk_io_percent(),
'system_uptime': self._format_uptime(system_uptime),
'bot_uptime': self.get_bot_uptime(),
'server_hostname': hostname,
'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
except Exception as e:
logger.error(f"Ошибка при получении информации о системе: {e}")
return {}
def _get_disk_space_emoji(self, disk_percent: float) -> str:
"""Получение эмодзи для дискового пространства"""
if disk_percent < 60:
return "🟢"
elif disk_percent < 90:
return "⚠️"
else:
return "🚨"
def _format_bytes(self, bytes_value: int) -> str:
"""Форматирование байтов в человекочитаемый вид"""
if bytes_value == 0:
return "0 B"
size_names = ["B", "KB", "MB", "GB", "TB"]
i = 0
while bytes_value >= 1024 and i < len(size_names) - 1:
bytes_value /= 1024.0
i += 1
return f"{bytes_value:.1f} {size_names[i]}"
def _format_uptime(self, seconds: float) -> str:
"""Форматирование времени работы системы"""
days = int(seconds // 86400)
hours = int((seconds % 86400) // 3600)
minutes = int((seconds % 3600) // 60)
if days > 0:
return f"{days}д {hours}ч {minutes}м"
elif hours > 0:
return f"{hours}ч {minutes}м"
else:
return f"{minutes}м"
def check_process_status(self, process_name: str) -> Tuple[str, str]:
"""Проверка статуса процесса и возврат статуса с uptime"""
try:
# Сначала проверяем по PID файлу
pid_file = self.pid_files.get(process_name)
if pid_file and os.path.exists(pid_file):
try:
with open(pid_file, 'r') as f:
content = f.read().strip()
if content and content != '# Этот файл будет автоматически обновляться при запуске бота':
pid = int(content)
if psutil.pid_exists(pid):
# Получаем uptime процесса
try:
proc = psutil.Process(pid)
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
except (ValueError, FileNotFoundError):
pass
# Проверяем по имени процесса более точно
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
proc_name = proc.info['name'].lower()
cmdline = ' '.join(proc.info['cmdline']).lower() if proc.info['cmdline'] else ''
# Более точная проверка для каждого бота
if process_name == 'voice_bot':
# Проверяем voice_bot
if ('voice_bot' in proc_name or
'voice_bot' in cmdline or
'voice_bot_v2.py' in cmdline):
# Получаем uptime процесса
try:
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
elif process_name == 'helper_bot':
# Проверяем helper_bot
if ('helper_bot' in proc_name or
'helper_bot' in cmdline or
'run_helper.py' in cmdline or
'python' in proc_name and 'helper_bot' in cmdline):
# Получаем uptime процесса
try:
proc_uptime = time.time() - proc.create_time()
uptime_str = self._format_uptime(proc_uptime)
return "", f"Uptime {uptime_str}"
except:
return "", "Uptime неизвестно"
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return "", "Выключен"
except Exception as e:
logger.error(f"Ошибка при проверке процесса {process_name}: {e}")
return "", "Выключен"
def should_send_status(self) -> bool:
"""Проверка, нужно ли отправить статус (каждые 30 минут в 00 и 30 минут часа)"""
now = datetime.now()
# Проверяем, что сейчас 00 или 30 минут часа
if now.minute in [0, 30]:
# Проверяем, не отправляли ли мы уже статус в эту минуту
if (self.last_status_time is None or
self.last_status_time.hour != now.hour or
self.last_status_time.minute != now.minute):
self.last_status_time = now
return True
return False
def _calculate_disk_speed(self, current_disk_io) -> Tuple[str, str]:
"""Расчет скорости чтения/записи диска"""
current_time = time.time()
if self.last_disk_io is None or self.last_disk_io_time is None:
self.last_disk_io = current_disk_io
self.last_disk_io_time = current_time
return "0 B/s", "0 B/s"
time_diff = current_time - self.last_disk_io_time
if time_diff < 1: # Минимальный интервал 1 секунда
return "0 B/s", "0 B/s"
read_diff = current_disk_io.read_bytes - self.last_disk_io.read_bytes
write_diff = current_disk_io.write_bytes - self.last_disk_io.write_bytes
read_speed = read_diff / time_diff
write_speed = write_diff / time_diff
# Обновляем предыдущие значения
self.last_disk_io = current_disk_io
self.last_disk_io_time = current_time
return self._format_bytes(read_speed) + "/s", self._format_bytes(write_speed) + "/s"
def _calculate_disk_io_percent(self) -> int:
"""Расчет процента загрузки диска на основе IOPS"""
try:
# Получаем статистику диска
disk_io = self._get_disk_io_counters()
if disk_io is None:
return 0
# Простая эвристика: считаем общее количество операций
total_ops = disk_io.read_count + disk_io.write_count
# Нормализуем к проценту (это приблизительная оценка)
# На macOS обычно нормальная нагрузка до 1000-5000 операций в секунду
if total_ops < 1000:
return 10
elif total_ops < 5000:
return 30
elif total_ops < 10000:
return 50
elif total_ops < 20000:
return 70
else:
return 90
except:
return 0
def should_send_startup_status(self) -> bool:
"""Проверка, нужно ли отправить статус при запуске"""
return self.last_status_time is None
async def send_startup_message(self):
"""Отправка сообщения о запуске бота"""
try:
message = f"""🚀 **Бот запущен!**
---------------------------------
**Время запуска:** <code>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</code>
**Сервер:** `{psutil.os.uname().nodename}`
**Система:** {psutil.os.uname().sysname} {psutil.os.uname().release}
**ОС:** {self.os_type.upper()}
✅ Мониторинг сервера активирован
✅ Статус будет отправляться каждые 30 минут (в 00 и 30 минут часа)
✅ Алерты будут отправляться при превышении пороговых значений
---------------------------------"""
await self.bot.send_message(
chat_id=self.important_logs,
text=message,
parse_mode='HTML'
)
logger.info("Сообщение о запуске бота отправлено")
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о запуске: {e}")
async def send_shutdown_message(self):
"""Отправка сообщения об отключении бота"""
try:
# Получаем финальную информацию о системе
system_info = self.get_system_info()
if not system_info:
system_info = {
'current_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'server_hostname': psutil.os.uname().nodename
}
message = f"""🛑 **Бот отключен!**
---------------------------------
**Время отключения:** <code>{system_info['current_time']}</code>
**Сервер:** `{system_info['server_hostname']}`
❌ Мониторинг сервера остановлен
❌ Статус больше не будет отправляться
❌ Алерты отключены
⚠️ **Внимание:** Проверьте состояние сервера!
---------------------------------"""
await self.bot.send_message(
chat_id=self.important_logs,
text=message,
parse_mode='HTML'
)
logger.info("Сообщение об отключении бота отправлено")
except Exception as e:
logger.error(f"Ошибка при отправке сообщения об отключении: {e}")
def check_alerts(self, system_info: Dict) -> Tuple[bool, Optional[str]]:
"""Проверка необходимости отправки алертов"""
alerts = []
# Проверка CPU
if system_info['cpu_percent'] > self.threshold and not self.alert_states['cpu']:
self.alert_states['cpu'] = True
alerts.append(('cpu', system_info['cpu_percent'], f"Нагрузка за 1 мин: {system_info['load_avg_1m']}"))
# Проверка RAM
if system_info['ram_percent'] > self.threshold and not self.alert_states['ram']:
self.alert_states['ram'] = True
alerts.append(('ram', system_info['ram_percent'], f"Используется: {system_info['ram_used']} GB из {system_info['ram_total']} GB"))
# Проверка диска
if system_info['disk_percent'] > self.threshold and not self.alert_states['disk']:
self.alert_states['disk'] = True
alerts.append(('disk', system_info['disk_percent'], f"Свободно: {system_info['disk_free']} GB на /"))
# Проверка восстановления
recoveries = []
if system_info['cpu_percent'] < self.recovery_threshold and self.alert_states['cpu']:
self.alert_states['cpu'] = False
recoveries.append(('cpu', system_info['cpu_percent']))
if system_info['ram_percent'] < self.recovery_threshold and self.alert_states['ram']:
self.alert_states['ram'] = False
recoveries.append(('ram', system_info['ram_percent']))
if system_info['disk_percent'] < self.recovery_threshold and self.alert_states['disk']:
self.alert_states['disk'] = False
recoveries.append(('disk', system_info['disk_percent']))
return alerts, recoveries
async def send_status_message(self, system_info: Dict):
"""Отправка сообщения со статусом сервера"""
try:
voice_bot_status, voice_bot_uptime = self.check_process_status('voice_bot')
helper_bot_status, helper_bot_uptime = self.check_process_status('helper_bot')
# Получаем эмодзи для дискового пространства
disk_emoji = self._get_disk_space_emoji(system_info['disk_percent'])
message = f"""🖥 **Статус Сервера** | <code>{system_info['current_time']}</code>
---------------------------------
**📊 Общая нагрузка:**
CPU: <b>{system_info['cpu_percent']}%</b> | LA: <b>{system_info['load_avg_1m']} / {system_info['cpu_count']}</b> | IO Wait: <b>{system_info['disk_percent']}%</b>
**💾 Память:**
RAM: <b>{system_info['ram_used']}/{system_info['ram_total']} GB</b> ({system_info['ram_percent']}%)
Swap: <b>{system_info['swap_used']}/{system_info['swap_total']} GB</b> ({system_info['swap_percent']}%)
**🗂️ Дисковое пространство:**
Диск (/): <b>{system_info['disk_used']}/{system_info['disk_total']} GB</b> ({system_info['disk_percent']}%) {disk_emoji}
**💿 Диск I/O:**
Read: <b>{system_info['disk_read_speed']}</b> | Write: <b>{system_info['disk_write_speed']}</b>
Диск загружен: <b>{system_info['disk_io_percent']}%</b>
**🤖 Процессы:**
{voice_bot_status} voice-bot - {voice_bot_uptime}
{helper_bot_status} helper-bot - {helper_bot_uptime}
---------------------------------
⏰ Uptime сервера: {system_info['system_uptime']}"""
await self.bot.send_message(
chat_id=self.group_for_logs,
text=message,
parse_mode='HTML'
)
logger.info("Статус сервера отправлен")
except Exception as e:
logger.error(f"Ошибка при отправке статуса сервера: {e}")
async def send_alert_message(self, metric_name: str, current_value: float, details: str):
"""Отправка сообщения об алерте"""
try:
message = f"""🚨 **ALERT: Высокая нагрузка на сервере!**
---------------------------------
**Показатель:** {metric_name}
**Текущее значение:** <b>{current_value}%</b> ⚠️
**Пороговое значение:** 80%
**Детали:**
{details}
**Сервер:** `{psutil.os.uname().nodename}`
**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}`
---------------------------------"""
await self.bot.send_message(
chat_id=self.important_logs,
text=message,
parse_mode='HTML'
)
logger.warning(f"Алерт отправлен: {metric_name} - {current_value}%")
except Exception as e:
logger.error(f"Ошибка при отправке алерта: {e}")
async def send_recovery_message(self, metric_name: str, current_value: float, peak_value: float):
"""Отправка сообщения о восстановлении"""
try:
message = f"""✅ **RECOVERY: Нагрузка нормализовалась**
---------------------------------
**Показатель:** {metric_name}
**Текущее значение:** <b>{current_value}%</b> ✔️
**Было превышение:** До {peak_value}%
**Сервер:** `{psutil.os.uname().nodename}`
**Время:** `{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}`
---------------------------------"""
await self.bot.send_message(
chat_id=self.important_logs,
text=message,
parse_mode='HTML'
)
logger.info(f"Сообщение о восстановлении отправлено: {metric_name}")
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о восстановлении: {e}")
async def monitor_loop(self):
"""Основной цикл мониторинга"""
logger.info(f"Модуль мониторинга сервера запущен на {self.os_type.upper()}")
# Отправляем сообщение о запуске при первом запуске
if self.should_send_startup_status():
await self.send_startup_message()
while True:
try:
system_info = self.get_system_info()
if not system_info:
await asyncio.sleep(60)
continue
# Проверка алертов
alerts, recoveries = self.check_alerts(system_info)
# Отправка алертов
for metric_type, value, details in alerts:
metric_names = {
'cpu': 'Использование CPU',
'ram': 'Использование оперативной памяти',
'disk': 'Заполнение диска (/)'
}
await self.send_alert_message(metric_names[metric_type], value, details)
# Отправка сообщений о восстановлении
for metric_type, value in recoveries:
metric_names = {
'cpu': 'Использование CPU',
'ram': 'Использование оперативной памяти',
'disk': 'Заполнение диска (/)'
}
# Находим пиковое значение (используем 80% как пример)
await self.send_recovery_message(metric_names[metric_type], value, 80.0)
# Отправка статуса каждые 30 минут в 00 и 30 минут часа
if self.should_send_status():
await self.send_status_message(system_info)
# Пауза между проверками (1 минута)
await asyncio.sleep(60)
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await asyncio.sleep(60)

View File

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

View File

@@ -0,0 +1,5 @@
"""
Сервисы приложения.
Содержит бизнес-логику, не связанную напрямую с handlers.
"""

View File

@@ -0,0 +1,39 @@
"""
Сервисы для ML-скоринга постов.
Включает:
- RagApiClient - HTTP клиент для внешнего RAG API сервиса
- DeepSeekService - интеграция с DeepSeek API
- ScoringManager - объединение всех сервисов скоринга
"""
from .base import CombinedScore, ScoringResult, ScoringServiceProtocol
from .deepseek_service import DeepSeekService
from .exceptions import (
DeepSeekAPIError,
InsufficientExamplesError,
ModelNotLoadedError,
ScoringError,
TextTooShortError,
VectorStoreError,
)
from .rag_client import RagApiClient
from .scoring_manager import ScoringManager
__all__ = [
# Базовые классы
"ScoringResult",
"ScoringServiceProtocol",
"CombinedScore",
# Исключения
"ScoringError",
"ModelNotLoadedError",
"VectorStoreError",
"DeepSeekAPIError",
"InsufficientExamplesError",
"TextTooShortError",
# Сервисы
"RagApiClient",
"DeepSeekService",
"ScoringManager",
]

View File

@@ -0,0 +1,159 @@
"""
Базовые классы и протоколы для сервисов скоринга.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, Optional, Protocol
@dataclass
class ScoringResult:
"""
Результат оценки поста от одного сервиса.
Attributes:
score: Оценка от 0.0 до 1.0 (вероятность публикации)
source: Источник оценки ("deepseek", "rag", etc.)
model: Название используемой модели
confidence: Уверенность в оценке (опционально)
timestamp: Время получения оценки
metadata: Дополнительные данные
"""
score: float
source: str
model: str
confidence: Optional[float] = None
timestamp: int = field(default_factory=lambda: int(datetime.now().timestamp()))
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Валидация score в диапазоне [0.0, 1.0]."""
if not 0.0 <= self.score <= 1.0:
raise ValueError(
f"Score должен быть в диапазоне [0.0, 1.0], получено: {self.score}"
)
def to_dict(self) -> Dict[str, Any]:
"""Преобразует результат в словарь для сохранения в JSON."""
result = {
"score": round(self.score, 4),
"model": self.model,
"ts": self.timestamp,
}
if self.confidence is not None:
result["confidence"] = round(self.confidence, 4)
if self.metadata:
result["metadata"] = self.metadata
return result
@classmethod
def from_dict(cls, source: str, data: Dict[str, Any]) -> "ScoringResult":
"""Создает ScoringResult из словаря."""
return cls(
score=data["score"],
source=source,
model=data.get("model", "unknown"),
confidence=data.get("confidence"),
timestamp=data.get("ts", int(datetime.now().timestamp())),
metadata=data.get("metadata", {}),
)
@dataclass
class CombinedScore:
"""
Объединенный результат от всех сервисов скоринга.
Attributes:
deepseek: Результат от DeepSeek API (None если отключен/ошибка)
rag: Результат от RAG сервиса (None если отключен/ошибка)
errors: Словарь с ошибками по источникам
"""
deepseek: Optional[ScoringResult] = None
rag: Optional[ScoringResult] = None
errors: Dict[str, str] = field(default_factory=dict)
@property
def deepseek_score(self) -> Optional[float]:
"""Возвращает только числовой скор от DeepSeek."""
return self.deepseek.score if self.deepseek else None
@property
def rag_score(self) -> Optional[float]:
"""Возвращает только числовой скор от RAG."""
return self.rag.score if self.rag else None
def to_json_dict(self) -> Dict[str, Any]:
"""
Преобразует в словарь для сохранения в ml_scores колонку.
Формат:
{
"deepseek": {"score": 0.75, "model": "...", "ts": ...},
"rag": {"score": 0.90, "model": "...", "ts": ...}
}
"""
result = {}
if self.deepseek:
result["deepseek"] = self.deepseek.to_dict()
if self.rag:
result["rag"] = self.rag.to_dict()
return result
def has_any_score(self) -> bool:
"""Проверяет, есть ли хотя бы один успешный скор."""
return self.deepseek is not None or self.rag is not None
class ScoringServiceProtocol(Protocol):
"""
Протокол для сервисов скоринга.
Любой сервис скоринга должен реализовывать эти методы.
"""
@property
def source_name(self) -> str:
"""Возвращает имя источника ("deepseek", "rag", etc.)."""
...
@property
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис."""
...
async def calculate_score(self, text: str) -> ScoringResult:
"""
Рассчитывает скор для текста поста.
Args:
text: Текст поста для оценки
Returns:
ScoringResult с оценкой
Raises:
ScoringError: При ошибке расчета
"""
...
async def add_positive_example(self, text: str) -> None:
"""
Добавляет текст как положительный пример (опубликованный пост).
Args:
text: Текст опубликованного поста
"""
...
async def add_negative_example(self, text: str) -> None:
"""
Добавляет текст как отрицательный пример (отклоненный пост).
Args:
text: Текст отклоненного поста
"""
...

View File

@@ -0,0 +1,365 @@
"""
DeepSeek API сервис для скоринга постов.
Использует DeepSeek API для семантической оценки релевантности поста.
"""
import asyncio
import json
from typing import List, Optional
import httpx
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
from .base import ScoringResult
from .exceptions import DeepSeekAPIError, ScoringError, TextTooShortError
class DeepSeekService:
"""
Сервис для оценки постов через DeepSeek API.
Отправляет текст поста в DeepSeek с промптом для оценки
и получает числовой скор релевантности.
Attributes:
api_key: API ключ DeepSeek
api_url: URL API эндпоинта
model: Название модели
timeout: Таймаут запроса в секундах
"""
# Промпт для оценки поста
SCORING_PROMPT = """Роль: Ты — строгий и внимательный модератор сообщества в социальной сети, ориентированного на знакомства между людьми. Твоя задача — оценить, можно ли опубликовать пост, основываясь на четких правилах.
Контекст группы: Это группа для поиска и знакомства с людьми. Пользователи могут искать кого угодно: случайно увиденных на улице, в транспорте, в кафе, старых знакомых, новых друзей или пару. Это главная и единственная цель группы.
---
ПРАВИЛА ЗАПРЕТА (пост НЕ ДОЛЖЕН быть опубликован, если содержит это):
1. Запрещенные законом тематики: Любые призывы, обсуждение или поиск чего-либо незаконного (наркотики, оружие, мошенничество, насилие и т.д.).
2. Поиск и утеря животных, найденные предметы: Запрещены посты про потерявшихся/найденных кошек, собак, хомяков, а также про потерянные/найденные телефоны, ключи, сумки и т.п.
3. Конкуренция (Дайвинчик): Любое упоминание группы/проекта/чата "Дайвинчик" или любых других групп-конкурентов. Запрещены призывы переходить в другие сообщества.
4. Сбор больших компаний и групп: Запрещены посты с целью собрать большую тусовку, компанию, группу для похода, вечеринки, игры и т.д. (например, "собираем команду для футбола", "кто хочет на квартиру?").
5. Организация чатов и других сообществ: Запрещено создание или реклама сторонних чатов, каналов, групп в телеграме, дискорде и т.п.
---
ПРАВИЛА РАЗРЕШЕНИЯ (пост МОЖЕТ быть опубликован, если):
· Цель — найти конкретного человека или познакомиться с кем-то новым.
· Формат: Описание человека, обстоятельств встречи, примет, места и времени. Или прямой призыв к знакомству.
· Примеры ДОПУСТИМЫХ постов (ориентируйся на них):
· "мальчики нефоры/патлатые, гоу знакомиться😻 анон"
· "ищу девочку, ехала на 21 автобусе примерно в 15:20. села на детской поликлинике и вышла в заречье вся в черной одежде и с черным баулом"
· "ищу мальчика ехали на 35 автобусе часов в 7 вечера я была с девочками,у нас с тобой еще куртки одинаковые ,я рядом с тобой сидела,напиши в комментарии если у тебя нету девочки. анон админу любви."
---
ИНСТРУКЦИЯ ПО ОЦЕНКЕ:
Проанализируй полученный пост и присвой ему итоговый Вес (Score) от 0.0 до 1.0, где:
· 1.0 — Пост полностью соответствует правилам. Цель — найти/познакомиться с человеком. Ничего из списка запретов не нарушено. Можно публиковать.
· 0.0 — Пост категорически нарушает правила. Содержит явные признаки одного или нескольких пунктов из списка запрета. Публиковать НЕЛЬЗЯ.
· 0.2 - 0.8 — Пост находится в "серой зоне". Присваивай промежуточный вес, оценивая степень риска и соответствия цели группы.
· Ближе к 0.2: Сильно сомнительный пост, есть явные признаки запрещенной темы (например, упоминание "собраться компанией", косвенная реклама другого места).
· 0.5: Нейтральный или неочевидный пост. Нужно проверить, нет ли скрытого смысла, нарушающего правила.
· Ближе к 0.8: В целом допустимый пост, но с небольшими странностями или двусмысленностями, не нарушающими правила напрямую.
---
{text}
---
Ответь ТОЛЬКО числом от 0.0 до 1.0, без дополнительных объяснений.
Пример ответа: 0.75"""
DEFAULT_API_URL = "https://api.deepseek.com/v1/chat/completions"
DEFAULT_MODEL = "deepseek-chat"
def __init__(
self,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
model: Optional[str] = None,
timeout: int = 30,
enabled: bool = True,
min_text_length: int = 3,
max_retries: int = 3,
):
"""
Инициализация DeepSeek сервиса.
Args:
api_key: API ключ DeepSeek
api_url: URL API эндпоинта
model: Название модели
timeout: Таймаут запроса в секундах
enabled: Включен ли сервис
min_text_length: Минимальная длина текста для обработки
max_retries: Максимальное количество повторных попыток
"""
self.api_key = api_key
self.api_url = api_url or self.DEFAULT_API_URL
self.model = model or self.DEFAULT_MODEL
self.timeout = timeout
self._enabled = enabled and bool(api_key)
self.min_text_length = min_text_length
self.max_retries = max_retries
# HTTP клиент (создается лениво)
self._client: Optional[httpx.AsyncClient] = None
if not api_key and enabled:
logger.warning("DeepSeekService: API ключ не указан, сервис отключен")
self._enabled = False
logger.info(
f"DeepSeekService инициализирован "
f"(model={self.model}, enabled={self._enabled})"
)
@property
def source_name(self) -> str:
"""Имя источника для результатов."""
return "deepseek"
@property
def is_enabled(self) -> bool:
"""Проверяет, включен ли сервис."""
return self._enabled
async def _get_client(self) -> httpx.AsyncClient:
"""Получает или создает HTTP клиент."""
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
return self._client
async def close(self) -> None:
"""Закрывает HTTP клиент."""
if self._client:
await self._client.aclose()
self._client = None
def _clean_text(self, text: str) -> str:
"""Очищает текст от лишних символов."""
if not text:
return ""
# Удаляем лишние пробелы и переносы строк
clean = " ".join(text.split())
# Удаляем служебные символы
if clean == "^":
return ""
return clean.strip()
def _parse_score_response(self, response_text: str) -> float:
"""
Парсит ответ от DeepSeek и извлекает скор.
Args:
response_text: Текст ответа от API
Returns:
Числовой скор от 0.0 до 1.0
Raises:
DeepSeekAPIError: Если не удалось распарсить ответ
"""
try:
# Пытаемся найти число в ответе
text = response_text.strip()
# Убираем возможные обрамления
text = text.strip("\"'`")
# Пробуем распарсить как число
score = float(text)
# Ограничиваем диапазон
score = max(0.0, min(1.0, score))
return score
except ValueError:
# Пробуем найти число в тексте
import re
matches = re.findall(r"0\.\d+|1\.0|0|1", text)
if matches:
score = float(matches[0])
return max(0.0, min(1.0, score))
logger.error(
f"DeepSeekService: Не удалось распарсить ответ: {response_text}"
)
raise DeepSeekAPIError(
f"Не удалось распарсить скор из ответа: {response_text}"
)
@track_time("calculate_score", "deepseek_service")
@track_errors("deepseek_service", "calculate_score")
async def calculate_score(self, text: str) -> ScoringResult:
"""
Рассчитывает скор для текста поста через DeepSeek API.
Args:
text: Текст поста для оценки
Returns:
ScoringResult с оценкой
Raises:
ScoringError: При ошибке расчета
"""
if not self._enabled:
raise ScoringError("DeepSeek сервис отключен")
# Очищаем текст
clean_text = self._clean_text(text)
if len(clean_text) < self.min_text_length:
raise TextTooShortError(
f"Текст слишком короткий (минимум {self.min_text_length} символов)"
)
# Формируем промпт
prompt = self.SCORING_PROMPT.format(text=clean_text)
# Выполняем запрос с повторными попытками
last_error = None
for attempt in range(self.max_retries):
try:
score = await self._make_api_request(prompt)
return ScoringResult(
score=score,
source=self.source_name,
model=self.model,
metadata={
"text_length": len(clean_text),
"attempt": attempt + 1,
},
)
except DeepSeekAPIError as e:
last_error = e
logger.warning(
f"DeepSeekService: Попытка {attempt + 1}/{self.max_retries} "
f"не удалась: {e}"
)
if attempt < self.max_retries - 1:
# Экспоненциальная задержка
await asyncio.sleep(2**attempt)
raise ScoringError(
f"Все попытки запроса к DeepSeek API не удались: {last_error}"
)
async def _make_api_request(self, prompt: str) -> float:
"""
Выполняет запрос к DeepSeek API.
Args:
prompt: Промпт для отправки
Returns:
Числовой скор от 0.0 до 1.0
Raises:
DeepSeekAPIError: При ошибке API
"""
client = await self._get_client()
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": prompt,
}
],
"temperature": 0.1, # Низкая температура для детерминированности
"max_tokens": 10, # Ожидаем только число
}
try:
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
data = response.json()
# Извлекаем ответ
if "choices" not in data or not data["choices"]:
raise DeepSeekAPIError("Пустой ответ от API")
response_text = data["choices"][0]["message"]["content"]
# Парсим скор
score = self._parse_score_response(response_text)
logger.debug(f"DeepSeekService: Получен скор {score} для текста")
return score
except httpx.HTTPStatusError as e:
error_msg = f"HTTP ошибка {e.response.status_code}"
try:
error_data = e.response.json()
if "error" in error_data:
error_msg = error_data["error"].get("message", error_msg)
except Exception:
pass
raise DeepSeekAPIError(error_msg)
except httpx.TimeoutException:
raise DeepSeekAPIError(f"Таймаут запроса ({self.timeout}s)")
except Exception as e:
raise DeepSeekAPIError(f"Ошибка запроса: {e}")
async def add_positive_example(self, text: str) -> None:
"""
Добавляет текст как положительный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом.
Args:
text: Текст опубликованного поста
"""
# DeepSeek не использует примеры для обучения
# Промпт уже содержит критерии оценки
pass
async def add_negative_example(self, text: str) -> None:
"""
Добавляет текст как отрицательный пример.
Для DeepSeek не требуется хранить примеры - оценка выполняется
на основе промпта. Метод существует для совместимости с протоколом.
Args:
text: Текст отклоненного поста
"""
# DeepSeek не использует примеры для обучения
pass
def get_stats(self) -> dict:
"""Возвращает статистику сервиса."""
return {
"enabled": self._enabled,
"model": self.model,
"api_url": self.api_url,
"timeout": self.timeout,
"max_retries": self.max_retries,
}

View File

@@ -0,0 +1,39 @@
"""
Исключения для сервисов скоринга.
"""
class ScoringError(Exception):
"""Базовое исключение для ошибок скоринга."""
pass
class ModelNotLoadedError(ScoringError):
"""Модель не загружена или недоступна."""
pass
class VectorStoreError(ScoringError):
"""Ошибка при работе с хранилищем векторов."""
pass
class DeepSeekAPIError(ScoringError):
"""Ошибка при обращении к DeepSeek API."""
pass
class InsufficientExamplesError(ScoringError):
"""Недостаточно примеров для расчета скора."""
pass
class TextTooShortError(ScoringError):
"""Текст слишком короткий для векторизации."""
pass

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