From fecac6091ee14d39305f788a1d57d93ca3cf6cc3 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 23 Jan 2026 23:19:16 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=20S3=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=89=D0=B5=D0=BC=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BE=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В `env.example` добавлены настройки для S3 хранилища. - Обновлен файл зависимостей `requirements.txt`, добавлена библиотека `aioboto3` для работы с S3. - В `PostRepository` и `AsyncBotDB` реализованы методы для обновления и получения контента опубликованных постов. - Обновлены обработчики публикации постов для сохранения идентификаторов опубликованных сообщений и их контента. - Реализована логика сохранения медиафайлов в S3 или на локальный диск в зависимости от конфигурации. - Обновлены тесты для проверки нового функционала. --- database/async_db.py | 16 + database/repositories/post_repository.py | 79 +++++ database/schema.sql | 12 + env.example | 8 + .../handlers/callback/dependency_factory.py | 3 +- helper_bot/handlers/callback/services.py | 138 +++++++- .../handlers/private/private_handlers.py | 11 +- helper_bot/handlers/private/services.py | 49 +-- helper_bot/utils/base_dependency_factory.py | 30 ++ helper_bot/utils/helper_func.py | 299 ++++++++++++------ helper_bot/utils/s3_storage.py | 175 ++++++++++ requirements.txt | 5 +- scripts/add_published_posts_support.py | 166 ++++++++++ scripts/test_s3_connection.py | 144 +++++++++ 14 files changed, 992 insertions(+), 143 deletions(-) create mode 100644 helper_bot/utils/s3_storage.py create mode 100755 scripts/add_published_posts_support.py create mode 100755 scripts/test_s3_connection.py diff --git a/database/async_db.py b/database/async_db.py index 07973eb..b48f689 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -147,6 +147,22 @@ class AsyncBotDB: """Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом).""" return await self.get_post_content_from_telegram_by_last_id(helper_message_id) + async def get_post_content_by_message_id(self, message_id: int) -> List[Tuple[str, str]]: + """Получает контент одиночного поста по message_id.""" + return await self.factory.posts.get_post_content_by_message_id(message_id) + + async def update_published_message_id(self, original_message_id: int, published_message_id: int): + """Обновляет published_message_id для опубликованного поста.""" + await self.factory.posts.update_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): + """Добавляет контент опубликованного поста.""" + return await self.factory.posts.add_published_post_content(published_message_id, content_path, content_type) + + async def get_published_post_content(self, published_message_id: int) -> List[Tuple[str, str]]: + """Получает контент опубликованного поста.""" + return await self.factory.posts.get_published_post_content(published_message_id) + async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]: """Получает текст поста по helper_text_message_id.""" return await self.factory.posts.get_post_text_by_helper_id(last_post_id) diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index 9c03c3a..fe183ae 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -19,11 +19,19 @@ class PostRepository(DatabaseConnection): 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: + await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER') + except Exception: + # Поле уже существует, игнорируем ошибку + pass + # Таблица контента постов content_query = ''' CREATE TABLE IF NOT EXISTS content_post_from_telegram ( @@ -47,6 +55,26 @@ class PostRepository(DatabaseConnection): ''' 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: @@ -174,6 +202,20 @@ class PostRepository(DatabaseConnection): 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 = ?" @@ -252,3 +294,40 @@ class PostRepository(DatabaseConnection): 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 diff --git a/database/schema.sql b/database/schema.sql index da9e9aa..6fd16c5 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest ( 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 ); @@ -93,6 +94,15 @@ CREATE TABLE IF NOT EXISTS content_post_from_telegram ( FOREIGN KEY (message_id) REFERENCES post_from_telegram_suggest(message_id) ON DELETE CASCADE ); +-- 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 ( user_id INTEGER NOT NULL PRIMARY KEY, @@ -130,3 +140,5 @@ 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); diff --git a/env.example b/env.example index bb48ef3..dbab9a9 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py index ade17fc..d175608 100644 --- a/helper_bot/handlers/callback/dependency_factory.py +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -13,7 +13,8 @@ def get_post_publish_service() -> PostPublishService: db = bdf.get_db() settings = bdf.settings - return PostPublishService(None, db, settings) + s3_storage = bdf.get_s3_storage() + return PostPublishService(None, db, settings, s3_storage) def get_ban_service() -> BanService: diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index c8ce08f..1184ab9 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -3,6 +3,7 @@ import html from typing import Dict, Any from aiogram import Bot +from aiogram import types from aiogram.types import CallbackQuery from helper_bot.utils.helper_func import ( @@ -33,11 +34,12 @@ from helper_bot.utils.metrics import ( class PostPublishService: - def __init__(self, bot: Bot, db, settings: Dict[str, Any]): + def __init__(self, bot: Bot, db, settings: Dict[str, Any], s3_storage=None): # bot может быть None - в этом случае используем бота из контекста сообщения self.bot = bot self.db = db self.settings = settings + self.s3_storage = s3_storage self.group_for_posts = settings['Telegram']['group_for_posts'] self.main_public = settings['Telegram']['main_public'] self.important_logs = settings['Telegram']['important_logs'] @@ -98,9 +100,16 @@ class PostPublishService: # Формируем финальный текст с учетом is_anonymous formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) - await send_text_message(self.main_public, call.message, formatted_text) + sent_message = await send_text_message(self.main_public, call.message, formatted_text) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Текст сообщения опубликован в канале {self.main_public}.') + logger.info(f'Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_photo_post", "post_publish_service") @track_errors("post_publish_service", "_publish_photo_post") @@ -126,9 +135,19 @@ class PostPublishService: # Формируем финальный текст с учетом is_anonymous formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) - await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text) + sent_message = await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + + # Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл) + await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Пост с фото опубликован в канале {self.main_public}.') + logger.info(f'Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_video_post", "post_publish_service") @track_errors("post_publish_service", "_publish_video_post") @@ -154,9 +173,19 @@ class PostPublishService: # Формируем финальный текст с учетом is_anonymous formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) - await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text) + sent_message = await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + + # Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл) + await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Пост с видео опубликован в канале {self.main_public}.') + logger.info(f'Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_video_note_post", "post_publish_service") @track_errors("post_publish_service", "_publish_video_note_post") @@ -169,9 +198,19 @@ class PostPublishService: logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'") raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") - await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id) + sent_message = await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + + # Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл) + await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Пост с кружком опубликован в канале {self.main_public}.') + logger.info(f'Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_audio_post", "post_publish_service") @track_errors("post_publish_service", "_publish_audio_post") @@ -197,9 +236,19 @@ class PostPublishService: # Формируем финальный текст с учетом is_anonymous formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) - await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text) + sent_message = await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + + # Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл) + await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Пост с аудио опубликован в канале {self.main_public}.') + logger.info(f'Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_voice_post", "post_publish_service") @track_errors("post_publish_service", "_publish_voice_post") @@ -212,9 +261,19 @@ class PostPublishService: logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'") raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") - await send_voice_message(self.main_public, call.message, call.message.voice.file_id) + sent_message = await send_voice_message(self.main_public, call.message, call.message.voice.file_id) + + # Сохраняем published_message_id + await self.db.update_published_message_id( + original_message_id=call.message.message_id, + published_message_id=sent_message.message_id + ) + + # Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл) + await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id) + await self._delete_post_and_notify_author(call, author_id) - logger.info(f'Пост с войсом опубликован в канале {self.main_public}.') + logger.info(f'Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.') @track_time("_publish_media_group", "post_publish_service") @track_errors("post_publish_service", "_publish_media_group") @@ -259,17 +318,36 @@ class PostPublishService: # Отправляем медиагруппу в канал logger.info(f"Отправляю медиагруппу в канал {self.main_public}") - await send_media_group_to_channel( + sent_messages = await send_media_group_to_channel( bot=self._get_bot(call.message), chat_id=self.main_public, post_content=post_content, - post_text=formatted_text + post_text=formatted_text, + s3_storage=self.s3_storage ) + + # Получаем оригинальные message_id из медиагруппы + original_message_ids = await self.db.get_post_ids_from_telegram_by_last_id(helper_message_id) + logger.debug(f"Получены оригинальные message_id медиагруппы: {original_message_ids}") + + # Сохраняем published_message_id для каждого сообщения медиагруппы + if len(sent_messages) == len(original_message_ids): + for i, original_message_id in enumerate(original_message_ids): + published_message_id = sent_messages[i].message_id + await self.db.update_published_message_id( + original_message_id=original_message_id, + published_message_id=published_message_id + ) + # Сохраняем медиафайл из опубликованного сообщения (используем уже сохраненный файл) + await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id) + logger.debug(f"Сохранен published_message_id: {original_message_id} -> {published_message_id}") + else: + logger.warning(f"Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(original_message_ids)})") await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved") logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}") await self._delete_media_group_and_notify_author(call, author_id) - logger.info(f'Медиагруппа опубликована в канале {self.main_public}.') + logger.info(f'Медиагруппа опубликована в канале {self.main_public}, опубликовано сообщений: {len(sent_messages)}.') except Exception as e: logger.error(f"Ошибка при публикации медиагруппы: {e}") @@ -412,6 +490,34 @@ class PostPublishService: raise UserBlockedBotError("Пользователь заблокировал бота") raise + @track_time("_save_published_post_content", "post_publish_service") + @track_errors("post_publish_service", "_save_published_post_content") + async def _save_published_post_content(self, published_message: types.Message, published_message_id: int, original_message_id: int) -> None: + """Сохраняет ссылку на медиафайл из опубликованного поста (файл уже в S3 или на диске).""" + try: + # Получаем уже сохраненный путь/S3 ключ из оригинального поста + saved_content = await self.db.get_post_content_by_message_id(original_message_id) + + if saved_content and len(saved_content) > 0: + # Копируем тот же путь/S3 ключ + file_path, content_type = saved_content[0] + logger.debug(f"Копируем путь/S3 ключ для опубликованного поста: {file_path}") + + success = await self.db.add_published_post_content( + published_message_id=published_message_id, + content_path=file_path, # Тот же путь/S3 ключ + content_type=content_type + ) + if success: + logger.info(f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}") + else: + logger.warning(f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}") + else: + logger.warning(f"Контент не найден для оригинального поста message_id={original_message_id}") + except Exception as e: + logger.error(f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}") + # Не прерываем публикацию, если сохранение контента не удалось + class BanService: def __init__(self, bot: Bot, db, settings: Dict[str, Any]): diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index c9ad68d..05a21b9 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -44,11 +44,11 @@ sleep = asyncio.sleep class PrivateHandlers: """Main handler class for private messages""" - def __init__(self, db: AsyncBotDB, settings: BotSettings): + def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None): self.db = db self.settings = settings self.user_service = UserService(db, settings) - self.post_service = PostService(db, settings) + self.post_service = PostService(db, settings, s3_storage) self.sticker_service = StickerService(settings) # Create router @@ -224,9 +224,9 @@ class PrivateHandlers: # Factory function to create handlers with dependencies -def create_private_handlers(db: AsyncBotDB, settings: BotSettings) -> PrivateHandlers: +def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None) -> PrivateHandlers: """Create private handlers instance with dependencies""" - return PrivateHandlers(db, settings) + return PrivateHandlers(db, settings, s3_storage) # Legacy router for backward compatibility @@ -252,7 +252,8 @@ def init_legacy_router(): ) db = bdf.get_db() - handlers = create_private_handlers(db, settings) + s3_storage = bdf.get_s3_storage() + handlers = create_private_handlers(db, settings, s3_storage) # Instead of trying to copy handlers, we'll use the new router directly # This maintains backward compatibility while using the new architecture diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 27bad6b..4341f13 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -143,9 +143,19 @@ class UserService: class PostService: """Service for post-related operations""" - def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: + def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None: self.db = db self.settings = settings + self.s3_storage = s3_storage + + async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None: + """Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю""" + try: + success = await add_in_db_media(sent_message, bot_db, s3_storage) + if not success: + logger.warning(f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}") + except Exception as e: + logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}") @track_time("handle_text_post", "post_service") @track_errors("post_service", "handle_text_post") @@ -155,14 +165,14 @@ class PostService: post_text = get_text_message(message.text.lower(), first_name, message.from_user.username) markup = get_reply_keyboard_for_post() - sent_message_id = await send_text_message(self.settings.group_for_posts, message, post_text, markup) + sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup) # Сохраняем сырой текст и определяем анонимность raw_text = message.text or "" is_anonymous = determine_anonymity(raw_text) post = TelegramPost( - message_id=sent_message_id, + message_id=sent_message.message_id, text=raw_text, author_id=message.from_user.id, created_at=int(datetime.now().timestamp()), @@ -196,9 +206,8 @@ class PostService: is_anonymous=is_anonymous ) await self.db.add_post(post) - success = await add_in_db_media(sent_message, self.db) - if not success: - logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) @track_time("handle_video_post", "post_service") @track_errors("post_service", "handle_video_post") @@ -226,9 +235,8 @@ class PostService: is_anonymous=is_anonymous ) await self.db.add_post(post) - success = await add_in_db_media(sent_message, self.db) - if not success: - logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) @track_time("handle_video_note_post", "post_service") @track_errors("post_service", "handle_video_note_post") @@ -252,9 +260,8 @@ class PostService: is_anonymous=is_anonymous ) await self.db.add_post(post) - success = await add_in_db_media(sent_message, self.db) - if not success: - logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) @track_time("handle_audio_post", "post_service") @track_errors("post_service", "handle_audio_post") @@ -282,9 +289,8 @@ class PostService: is_anonymous=is_anonymous ) await self.db.add_post(post) - success = await add_in_db_media(sent_message, self.db) - if not success: - logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) @track_time("handle_voice_post", "post_service") @track_errors("post_service", "handle_voice_post") @@ -308,9 +314,8 @@ class PostService: is_anonymous=is_anonymous ) await self.db.add_post(post) - success = await add_in_db_media(sent_message, self.db) - if not success: - logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) @track_time("handle_media_group_post", "post_service") @track_errors("post_service", "handle_media_group_post") @@ -342,18 +347,18 @@ class PostService: # Отправляем медиагруппу в группу для модерации media_group = await prepare_media_group_from_middlewares(album, post_caption) media_group_message_id = await send_media_group_message_to_private_chat( - self.settings.group_for_posts, message, media_group, self.db, main_post.message_id + self.settings.group_for_posts, message, media_group, self.db, main_post.message_id, self.s3_storage ) await asyncio.sleep(0.2) # Создаем helper сообщение с кнопками markup = get_reply_keyboard_for_post() - help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА") + help_message = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА") # Создаем helper пост и связываем его с основным helper_post = TelegramPost( - message_id=help_message_id, # ID helper сообщения + message_id=help_message.message_id, # ID helper сообщения text="^", # Специальный маркер для медиагруппы author_id=message.from_user.id, helper_text_message_id=main_post.message_id, # Ссылка на основной пост @@ -364,7 +369,7 @@ class PostService: # Обновляем основной пост, чтобы он ссылался на helper await self.db.update_helper_message( message_id=main_post.message_id, - helper_message_id=help_message_id + helper_message_id=help_message.message_id ) @track_time("process_post", "post_service") diff --git a/helper_bot/utils/base_dependency_factory.py b/helper_bot/utils/base_dependency_factory.py index 2da61a0..7959b34 100644 --- a/helper_bot/utils/base_dependency_factory.py +++ b/helper_bot/utils/base_dependency_factory.py @@ -1,8 +1,10 @@ import os import sys +from typing import Optional from dotenv import load_dotenv from database.async_db import AsyncBotDB +from helper_bot.utils.s3_storage import S3StorageService class BaseDependencyFactory: @@ -21,6 +23,7 @@ class BaseDependencyFactory: self.database = AsyncBotDB(database_path) self._load_settings_from_env() + self._init_s3_storage() def _load_settings_from_env(self): """Загружает настройки из переменных окружения.""" @@ -48,6 +51,29 @@ class BaseDependencyFactory: 'port': self._parse_int(os.getenv('METRICS_PORT', '8080')) } + self.settings['S3'] = { + 'enabled': self._parse_bool(os.getenv('S3_ENABLED', 'false')), + 'endpoint_url': os.getenv('S3_ENDPOINT_URL', ''), + 'access_key': os.getenv('S3_ACCESS_KEY', ''), + 'secret_key': os.getenv('S3_SECRET_KEY', ''), + 'bucket_name': os.getenv('S3_BUCKET_NAME', ''), + 'region': os.getenv('S3_REGION', 'us-east-1') + } + + def _init_s3_storage(self): + """Инициализирует S3StorageService если S3 включен.""" + self.s3_storage = None + if self.settings['S3']['enabled']: + s3_config = self.settings['S3'] + if s3_config['endpoint_url'] and s3_config['access_key'] and s3_config['secret_key'] and s3_config['bucket_name']: + self.s3_storage = S3StorageService( + endpoint_url=s3_config['endpoint_url'], + access_key=s3_config['access_key'], + secret_key=s3_config['secret_key'], + bucket_name=s3_config['bucket_name'], + region=s3_config['region'] + ) + def _parse_bool(self, value: str) -> bool: """Парсит строковое значение в boolean.""" return value.lower() in ('true', '1', 'yes', 'on') @@ -65,6 +91,10 @@ class BaseDependencyFactory: def get_db(self) -> AsyncBotDB: """Возвращает подключение к базе данных.""" return self.database + + def get_s3_storage(self) -> Optional[S3StorageService]: + """Возвращает S3StorageService если S3 включен, иначе None.""" + return self.s3_storage _global_instance = None diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 8e3dad7..360c1b7 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -2,6 +2,8 @@ import html import os import random import time +import tempfile +import asyncio from datetime import datetime, timedelta from time import sleep from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union @@ -158,17 +160,19 @@ def get_text_message(post_text: str, first_name: str, username: str = None, is_a @track_time("download_file", "helper_func") @track_errors("helper_func", "download_file") @track_file_operations("unknown") -async def download_file(message: types.Message, file_id: str, content_type: str = None) -> Optional[str]: +async def download_file(message: types.Message, file_id: str, content_type: str = None, + s3_storage = None) -> Optional[str]: """ - Скачивает файл по file_id из Telegram и сохраняет в соответствующую папку. + Скачивает файл по file_id из Telegram и сохраняет в S3 или на локальный диск. Args: message: сообщение file_id: File ID файла content_type: тип контента (photo, video, audio, voice, video_note) + s3_storage: опциональный S3StorageService для сохранения в S3 Returns: - Путь к сохраненному файлу, если файл был скачан успешно, иначе None + S3 ключ (если s3_storage указан) или локальный путь к файлу, иначе None """ start_time = time.time() @@ -178,51 +182,95 @@ async def download_file(message: types.Message, file_id: str, content_type: str logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют") return None - # Определяем папку по типу контента - type_folders = { - 'photo': 'photos', - 'video': 'videos', - 'audio': 'music', - 'voice': 'voice', - 'video_note': 'video_notes' - } - - folder = type_folders.get(content_type, 'other') - base_path = "files" - full_folder_path = os.path.join(base_path, folder) - - # Создаем необходимые папки - os.makedirs(base_path, exist_ok=True) - os.makedirs(full_folder_path, exist_ok=True) - - logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}") - # Получаем информацию о файле file = await message.bot.get_file(file_id) if not file or not file.file_path: logger.error(f"download_file: Не удалось получить информацию о файле {file_id}") return None - # Генерируем уникальное имя файла + # Определяем расширение original_filename = os.path.basename(file.file_path) file_extension = os.path.splitext(original_filename)[1] or '.bin' - safe_filename = f"{file_id}{file_extension}" - file_path = os.path.join(full_folder_path, safe_filename) - # Скачиваем файл - await message.bot.download_file(file_path=file.file_path, destination=file_path) - - # Проверяем, что файл действительно скачался - if not os.path.exists(file_path): - logger.error(f"download_file: Файл не был скачан - {file_path}") - return None - - file_size = os.path.getsize(file_path) - download_time = time.time() - start_time - - logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с") - - return file_path + if s3_storage: + # Сохраняем в S3 + # Скачиваем во временный файл + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension) + temp_path = temp_file.name + temp_file.close() + + try: + # Скачиваем из Telegram + await message.bot.download_file(file_path=file.file_path, destination=temp_path) + + # Генерируем S3 ключ + s3_key = s3_storage.generate_s3_key(content_type, file_id) + + # Загружаем в S3 + success = await s3_storage.upload_file(temp_path, s3_key) + + # Удаляем временный файл + try: + os.remove(temp_path) + except: + pass + + if success: + file_size = file.file_size if hasattr(file, 'file_size') else 0 + download_time = time.time() - start_time + logger.info(f"download_file: Файл загружен в S3 - {s3_key}, размер: {file_size} байт, время: {download_time:.2f}с") + return s3_key + else: + logger.error(f"download_file: Не удалось загрузить файл в S3: {s3_key}") + return None + except Exception as e: + # Удаляем временный файл при ошибке + try: + os.remove(temp_path) + except: + pass + download_time = time.time() - start_time + logger.error(f"download_file: Ошибка загрузки файла в S3 {file_id}: {e}, время: {download_time:.2f}с") + return None + else: + # Старая логика - сохраняем на локальный диск + # Определяем папку по типу контента + type_folders = { + 'photo': 'photos', + 'video': 'videos', + 'audio': 'music', + 'voice': 'voice', + 'video_note': 'video_notes' + } + + folder = type_folders.get(content_type, 'other') + base_path = "files" + full_folder_path = os.path.join(base_path, folder) + + # Создаем необходимые папки + os.makedirs(base_path, exist_ok=True) + os.makedirs(full_folder_path, exist_ok=True) + + logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}") + + # Генерируем уникальное имя файла + safe_filename = f"{file_id}{file_extension}" + file_path = os.path.join(full_folder_path, safe_filename) + + # Скачиваем файл + await message.bot.download_file(file_path=file.file_path, destination=file_path) + + # Проверяем, что файл действительно скачался + if not os.path.exists(file_path): + logger.error(f"download_file: Файл не был скачан - {file_path}") + return None + + file_size = os.path.getsize(file_path) + download_time = time.time() - start_time + + logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с") + + return file_path except Exception as e: download_time = time.time() - start_time @@ -283,11 +331,21 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''): return media_group +async def _save_media_group_background(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int], s3_storage) -> None: + """Сохраняет медиагруппу в фоне, чтобы не блокировать ответ пользователю""" + try: + success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id, s3_storage) + if not success: + logger.warning(f"_save_media_group_background: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}") + except Exception as e: + logger.error(f"_save_media_group_background: Ошибка при сохранении медиа для медиагруппы {sent_message[-1].message_id}: {e}") + @track_time("add_in_db_media_mediagroup", "helper_func") @track_errors("helper_func", "add_in_db_media_mediagroup") @track_media_processing("media_group") @db_query_time("add_in_db_media_mediagroup", "posts", "insert") -async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int] = None) -> bool: +async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, + main_post_id: Optional[int] = None, s3_storage = None) -> bool: """ Добавляет контент медиа-группы в базу данных @@ -351,23 +409,31 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}") - # Скачиваем файл - file_path = await download_file(message, file_id=file_id, content_type=content_type) + # Получаем s3_storage если не передан + if s3_storage is None: + bdf = get_global_instance() + s3_storage = bdf.get_s3_storage() + + # Скачиваем файл (в S3 или на локальный диск) + file_path = await download_file(message, file_id=file_id, content_type=content_type, s3_storage=s3_storage) if not file_path: logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}") failed_count += 1 continue # Добавляем в базу данных - success = await bot_db.add_post_content(post_id, message.message_id, file_path, content_type) + # Для медиагруппы используем post_id (основной пост) как message_id для контента, + # так как FOREIGN KEY требует существования message_id в post_from_telegram_suggest + success = await bot_db.add_post_content(post_id, post_id, file_path, content_type) if not success: logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}") - # Удаляем скачанный файл при ошибке БД - try: - os.remove(file_path) - logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД") - except Exception as e: - logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}") + # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3) + if file_path.startswith('files/'): + try: + os.remove(file_path) + logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД") + except Exception as e: + logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}") failed_count += 1 continue @@ -402,7 +468,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: @track_media_processing("media_group") @db_query_time("add_in_db_media", "posts", "insert") @track_file_operations("media") -async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: +async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage = None) -> bool: """ Добавляет контент одиночного сообщения в базу данных @@ -451,8 +517,13 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}") - # Скачиваем файл - file_path = await download_file(sent_message, file_id=file_id, content_type=content_type) + # Получаем s3_storage если не передан + if s3_storage is None: + bdf = get_global_instance() + s3_storage = bdf.get_s3_storage() + + # Скачиваем файл (в S3 или на локальный диск) + file_path = await download_file(sent_message, file_id=file_id, content_type=content_type, s3_storage=s3_storage) if not file_path: logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}") return False @@ -461,12 +532,13 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type) if not success: logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}") - # Удаляем скачанный файл при ошибке БД - try: - os.remove(file_path) - logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД") - except Exception as e: - logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}") + # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3) + if file_path.startswith('files/'): + try: + os.remove(file_path) + logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД") + except Exception as e: + logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}") return False processing_time = time.time() - start_time @@ -484,7 +556,7 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: @track_media_processing("media_group") @db_query_time("send_media_group_message_to_private_chat", "posts", "insert") async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, - media_group: List, bot_db: Any, main_post_id: Optional[int] = None) -> int: + media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> int: sent_message = await message.bot.send_media_group( chat_id=chat_id, media=media_group, @@ -496,62 +568,93 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types. created_at=int(datetime.now().timestamp()) ) await bot_db.add_post(post) - success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id) - if not success: - logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}") + # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю + asyncio.create_task(_save_media_group_background(sent_message, bot_db, main_post_id, s3_storage)) message_id = sent_message[-1].message_id return message_id @track_time("send_media_group_to_channel", "helper_func") @track_errors("helper_func", "send_media_group_to_channel") @track_media_processing("media_group") -async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str): +async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str, s3_storage = None): """ Отправляет медиа-группу с подписью к последнему файлу. Args: bot: Экземпляр бота aiogram. chat_id: ID чата для отправки. - post_content: Список кортежей с путями к файлам. + post_content: Список кортежей с путями к файлам (локальные пути или S3 ключи). post_text: Текст подписи. + s3_storage: опциональный S3StorageService для работы с S3. """ logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}") + # Получаем s3_storage если не передан + if s3_storage is None: + bdf = get_global_instance() + s3_storage = bdf.get_s3_storage() + media = [] - for i, file_path in enumerate(post_content): - try: - file = FSInputFile(path=file_path[0]) - type = file_path[1] - logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})") - - if type == 'video': - media.append(types.InputMediaVideo(media=file)) - elif type == 'photo': - media.append(types.InputMediaPhoto(media=file)) - else: - logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}") - except FileNotFoundError: - logger.error(f"Файл не найден: {file_path[0]}") - return - except Exception as e: - logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}") - return - - logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки") - - # Добавляем подпись к последнему файлу - if media: - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - media[-1].caption = safe_post_text - logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}") - + temp_files = [] # Для хранения путей к временным файлам + try: - await bot.send_media_group(chat_id=chat_id, media=media) - logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}") - except Exception as e: - logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}") - raise + for i, file_path_tuple in enumerate(post_content): + try: + file_path, content_type = file_path_tuple + logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path} (тип: {content_type})") + + # Проверяем, это S3 ключ или локальный путь + actual_path = file_path + if s3_storage and not file_path.startswith('files/') and not os.path.exists(file_path): + # Это S3 ключ, скачиваем во временный файл + temp_path = await s3_storage.download_to_temp(file_path) + if not temp_path: + logger.error(f"Не удалось скачать файл из S3: {file_path}") + continue + temp_files.append(temp_path) + actual_path = temp_path + elif not os.path.exists(file_path): + logger.error(f"Файл не найден: {file_path}") + continue + + file = FSInputFile(path=actual_path) + + if content_type == 'video': + media.append(types.InputMediaVideo(media=file)) + elif content_type == 'photo': + media.append(types.InputMediaPhoto(media=file)) + else: + logger.warning(f"Неизвестный тип файла: {content_type} для {file_path}") + except FileNotFoundError: + logger.error(f"Файл не найден: {file_path_tuple[0]}") + continue + except Exception as e: + logger.error(f"Ошибка при обработке файла {file_path_tuple[0]}: {e}") + continue + + logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки") + + # Добавляем подпись к последнему файлу + if media: + # Экранируем post_text для безопасного использования в HTML + safe_post_text = html.escape(str(post_text)) if post_text else "" + media[-1].caption = safe_post_text + logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}") + + try: + sent_messages = await bot.send_media_group(chat_id=chat_id, media=media) + logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}") + return sent_messages + except Exception as e: + logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}") + raise + finally: + # Удаляем временные файлы + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass @track_time("send_text_message", "helper_func") @track_errors("helper_func", "send_text_message") @@ -575,7 +678,7 @@ async def send_text_message(chat_id, message: types.Message, post_text: str, mar ) sent_message = await send_with_rate_limit(_send_message, chat_id) - return sent_message.message_id + return sent_message @track_time("send_photo_message", "helper_func") @track_errors("helper_func", "send_photo_message") diff --git a/helper_bot/utils/s3_storage.py b/helper_bot/utils/s3_storage.py new file mode 100644 index 0000000..090fa61 --- /dev/null +++ b/helper_bot/utils/s3_storage.py @@ -0,0 +1,175 @@ +""" +Сервис для работы с S3 хранилищем. +""" +import aioboto3 +import os +import tempfile +from typing import Optional +from pathlib import Path +from logs.custom_logger import logger + + +class S3StorageService: + """Сервис для работы с S3 хранилищем.""" + + def __init__(self, endpoint_url: str, access_key: str, secret_key: str, + bucket_name: str, region: str = "us-east-1"): + self.endpoint_url = endpoint_url + self.access_key = access_key + self.secret_key = secret_key + self.bucket_name = bucket_name + self.region = region + self.session = aioboto3.Session() + + async def upload_file(self, file_path: str, s3_key: str, + content_type: Optional[str] = None) -> bool: + """Загружает файл в S3.""" + try: + async with self.session.client( + 's3', + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) as s3: + extra_args = {} + if content_type: + extra_args['ContentType'] = content_type + + await s3.upload_file( + file_path, + self.bucket_name, + s3_key, + ExtraArgs=extra_args + ) + logger.info(f"Файл загружен в S3: {s3_key}") + return True + except Exception as e: + logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}") + return False + + async def upload_fileobj(self, file_obj, s3_key: str, + content_type: Optional[str] = None) -> bool: + """Загружает файл из объекта в S3.""" + try: + async with self.session.client( + 's3', + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) as s3: + extra_args = {} + if content_type: + extra_args['ContentType'] = content_type + + await s3.upload_fileobj( + file_obj, + self.bucket_name, + s3_key, + ExtraArgs=extra_args + ) + logger.info(f"Файл загружен в S3 из объекта: {s3_key}") + return True + except Exception as e: + logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}") + return False + + async def download_file(self, s3_key: str, local_path: str) -> bool: + """Скачивает файл из S3 на локальный диск.""" + try: + async with self.session.client( + 's3', + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) as s3: + # Создаем директорию если её нет + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + await s3.download_file( + self.bucket_name, + s3_key, + local_path + ) + logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}") + return True + except Exception as e: + logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}") + return False + + async def download_to_temp(self, s3_key: str) -> Optional[str]: + """Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу.""" + try: + # Определяем расширение из ключа + ext = Path(s3_key).suffix or '.bin' + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext) + temp_path = temp_file.name + temp_file.close() + + success = await self.download_file(s3_key, temp_path) + if success: + return temp_path + else: + # Удаляем временный файл при ошибке + try: + os.remove(temp_path) + except: + pass + return None + except Exception as e: + logger.error(f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}") + return None + + async def file_exists(self, s3_key: str) -> bool: + """Проверяет существование файла в S3.""" + try: + async with self.session.client( + 's3', + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) as s3: + await s3.head_object(Bucket=self.bucket_name, Key=s3_key) + return True + except: + return False + + async def delete_file(self, s3_key: str) -> bool: + """Удаляет файл из S3.""" + try: + async with self.session.client( + 's3', + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + region_name=self.region + ) as s3: + await s3.delete_object(Bucket=self.bucket_name, Key=s3_key) + logger.info(f"Файл удален из S3: {s3_key}") + return True + except Exception as e: + logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}") + return False + + def generate_s3_key(self, content_type: str, file_id: str) -> str: + """Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id.""" + type_folders = { + 'photo': 'photos', + 'video': 'videos', + 'audio': 'music', + 'voice': 'voice', + 'video_note': 'video_notes' + } + + folder = type_folders.get(content_type, 'other') + # Определяем расширение из file_id или используем дефолтное + ext = '.jpg' if content_type == 'photo' else \ + '.mp4' if content_type == 'video' else \ + '.mp3' if content_type == 'audio' else \ + '.ogg' if content_type == 'voice' else \ + '.mp4' if content_type == 'video_note' else '.bin' + + return f"{folder}/{file_id}{ext}" diff --git a/requirements.txt b/requirements.txt index 505c65c..4efef4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,4 +27,7 @@ charset-normalizer>=3.0.0 pluggy==1.5.0 attrs~=23.2.0 typing_extensions~=4.12.2 -emoji~=2.8.0 \ No newline at end of file +emoji~=2.8.0 + +# S3 Storage (для хранения медиафайлов опубликованных постов) +aioboto3>=12.0.0 \ No newline at end of file diff --git a/scripts/add_published_posts_support.py b/scripts/add_published_posts_support.py new file mode 100755 index 0000000..341c1e6 --- /dev/null +++ b/scripts/add_published_posts_support.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Скрипт миграции для добавления поддержки опубликованных постов: +1. Добавляет колонку published_message_id в таблицу post_from_telegram_suggest +2. Создает таблицу published_post_content для хранения медиафайлов опубликованных постов +3. Создает индексы для производительности +""" +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" + + +def _column_exists(rows: list, name: str) -> bool: + """Проверяет существование колонки в таблице. + PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk).""" + for row in rows: + if row[1] == name: + return True + return False + + + + +async def main(db_path: str, dry_run: bool = False) -> None: + """Выполняет миграцию БД для поддержки опубликованных постов.""" + db_path = os.path.abspath(db_path) + if not os.path.exists(db_path): + logger.error("База данных не найдена: %s", db_path) + print(f"Ошибка: база данных не найдена: {db_path}") + return + + async with aiosqlite.connect(db_path) as conn: + await conn.execute("PRAGMA foreign_keys = ON") + + changes_made = [] + + # 1. Проверяем и добавляем колонку published_message_id + cursor = await conn.execute( + "PRAGMA table_info(post_from_telegram_suggest)" + ) + rows = await cursor.fetchall() + await cursor.close() + + if not _column_exists(rows, "published_message_id"): + if dry_run: + print("DRY RUN: Будет добавлена колонка published_message_id в post_from_telegram_suggest") + changes_made.append("Добавление колонки published_message_id") + else: + logger.info("Добавление колонки published_message_id в post_from_telegram_suggest") + await conn.execute( + "ALTER TABLE post_from_telegram_suggest " + "ADD COLUMN published_message_id INTEGER" + ) + await conn.commit() + print("✓ Колонка published_message_id добавлена в post_from_telegram_suggest") + changes_made.append("Добавлена колонка published_message_id") + else: + print("✓ Колонка published_message_id уже существует в post_from_telegram_suggest") + + # 2. Проверяем и создаем таблицу published_post_content + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='published_post_content'" + ) + table_exists = await cursor.fetchone() + await cursor.close() + + if not table_exists: + if dry_run: + print("DRY RUN: Будет создана таблица published_post_content") + changes_made.append("Создание таблицы published_post_content") + else: + logger.info("Создание таблицы published_post_content") + await conn.execute(""" + 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 conn.commit() + print("✓ Таблица published_post_content создана") + changes_made.append("Создана таблица published_post_content") + else: + print("✓ Таблица published_post_content уже существует") + + # 3. Проверяем и создаем индексы + indexes = [ + ("idx_published_post_content_message_id", + "CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id " + "ON published_post_content(published_message_id)"), + ("idx_post_from_telegram_suggest_published", + "CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published " + "ON post_from_telegram_suggest(published_message_id)") + ] + + for index_name, index_sql in indexes: + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + (index_name,) + ) + index_exists = await cursor.fetchone() + await cursor.close() + + if not index_exists: + if dry_run: + print(f"DRY RUN: Будет создан индекс {index_name}") + changes_made.append(f"Создание индекса {index_name}") + else: + logger.info(f"Создание индекса {index_name}") + await conn.execute(index_sql) + await conn.commit() + print(f"✓ Индекс {index_name} создан") + changes_made.append(f"Создан индекс {index_name}") + else: + print(f"✓ Индекс {index_name} уже существует") + + # Финальная статистика + if dry_run: + if changes_made: + print("\n" + "="*60) + print("DRY RUN: Следующие изменения будут выполнены:") + for change in changes_made: + print(f" - {change}") + print("="*60) + else: + print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.") + else: + if changes_made: + logger.info(f"Миграция завершена. Выполнено изменений: {len(changes_made)}") + print(f"\n✓ Миграция завершена успешно!") + print(f"Выполнено изменений: {len(changes_made)}") + for change in changes_made: + print(f" - {change}") + else: + print("\n✓ Все необходимые изменения уже применены. Ничего делать не нужно.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Добавление поддержки опубликованных постов в БД" + ) + parser.add_argument( + "--db", + default=os.environ.get("DB_PATH", DEFAULT_DB_PATH), + help="Путь к БД (или переменная окружения DB_PATH)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Показать что будет сделано без выполнения изменений", + ) + args = parser.parse_args() + asyncio.run(main(args.db, dry_run=args.dry_run)) diff --git a/scripts/test_s3_connection.py b/scripts/test_s3_connection.py new file mode 100755 index 0000000..fe37fc7 --- /dev/null +++ b/scripts/test_s3_connection.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Скрипт для проверки подключения к S3 хранилищу. +Читает настройки из .env файла или переменных окружения. +""" +import asyncio +import os +import sys +from pathlib import Path + +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root)) + +# Загружаем .env файл +from dotenv import load_dotenv +env_path = os.path.join(project_root, '.env') +if os.path.exists(env_path): + load_dotenv(env_path) + +try: + import aioboto3 +except ImportError: + print("❌ Библиотека aioboto3 не установлена.") + print("Установите её командой: pip install aioboto3") + sys.exit(1) + +# Данные для подключения из .env или переменных окружения +S3_ACCESS_KEY = os.getenv('S3_ACCESS_KEY', 'j3tears100@gmail.com') +S3_SECRET_KEY = os.getenv('S3_SECRET_KEY', 'wQ1-6sZEPs92sbZTSf96') +S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL', 'https://api.s3.miran.ru:443') +S3_BUCKET_NAME = os.getenv('S3_BUCKET_NAME', 'telegram-helper-bot') +S3_REGION = os.getenv('S3_REGION', 'us-east-1') + +async def test_s3_connection(): + """Тестирует подключение к S3 хранилищу.""" + print("🔍 Тестирование подключения к S3 хранилищу...") + print(f"Endpoint: {S3_ENDPOINT_URL}") + print(f"Bucket: {S3_BUCKET_NAME}") + print(f"Region: {S3_REGION}") + print(f"Access Key: {S3_ACCESS_KEY}") + print() + + session = aioboto3.Session() + + try: + async with session.client( + 's3', + endpoint_url=S3_ENDPOINT_URL, + aws_access_key_id=S3_ACCESS_KEY, + aws_secret_access_key=S3_SECRET_KEY, + region_name=S3_REGION + ) as s3: + # Пытаемся получить список бакетов (может не иметь прав, пропускаем если ошибка) + print("📦 Получение списка бакетов...") + try: + response = await s3.list_buckets() + buckets = response.get('Buckets', []) + print(f"✅ Подключение успешно! Найдено бакетов: {len(buckets)}") + + if buckets: + print("\n📋 Список бакетов:") + for bucket in buckets: + print(f" - {bucket['Name']} (создан: {bucket.get('CreationDate', 'неизвестно')})") + else: + print("\n⚠️ Бакеты не найдены.") + except Exception as list_error: + print(f"⚠️ Не удалось получить список бакетов: {list_error}") + print(" Это нормально, если нет прав на list_buckets") + print(" Продолжаем тестирование с указанным бакетом...") + + # Пытаемся создать тестовый файл в указанном бакете + print("\n🧪 Тестирование записи файла...") + # Используем первый найденный бакет, если указанный не найден + test_bucket = S3_BUCKET_NAME + if buckets: + # Проверяем, есть ли указанный бакет в списке + bucket_names = [b['Name'] for b in buckets] + if test_bucket not in bucket_names: + print(f"⚠️ Бакет '{test_bucket}' не найден в списке.") + print(f" Используем первый найденный бакет: '{buckets[0]['Name']}'") + test_bucket = buckets[0]['Name'] + + test_key = 'test-connection.txt' + test_content = b'Test connection to S3 storage' + + try: + # Проверяем существование бакета + try: + await s3.head_bucket(Bucket=test_bucket) + print(f"✅ Бакет '{test_bucket}' существует и доступен") + except Exception as head_error: + print(f"❌ Бакет '{test_bucket}' недоступен: {head_error}") + print(" Проверьте права доступа к бакету") + return False + + await s3.put_object( + Bucket=test_bucket, + Key=test_key, + Body=test_content + ) + print(f"✅ Файл успешно записан в бакет '{test_bucket}' с ключом '{test_key}'") + + # Пытаемся прочитать файл + print("🧪 Тестирование чтения файла...") + response = await s3.get_object(Bucket=test_bucket, Key=test_key) + content = await response['Body'].read() + + if content == test_content: + print("✅ Файл успешно прочитан, содержимое совпадает") + else: + print("⚠️ Файл прочитан, но содержимое не совпадает") + + # Удаляем тестовый файл + print("🧹 Удаление тестового файла...") + await s3.delete_object(Bucket=test_bucket, Key=test_key) + print("✅ Тестовый файл удален") + + except Exception as e: + print(f"❌ Ошибка при тестировании записи/чтения: {e}") + print(f" Тип ошибки: {type(e).__name__}") + import traceback + print(f" Полный traceback:") + traceback.print_exc() + print("\nВозможные причины:") + print(" 1. Неверное имя бакета") + print(" 2. Нет прав на запись в бакет") + print(" 3. Неверный endpoint URL или регион") + print(" 4. Проблемы с форматом endpoint (попробуйте без :443)") + + return True + + except Exception as e: + print(f"❌ Ошибка подключения к S3: {e}") + print("\nВозможные причины:") + print(" 1. Неверные credentials (Access Key / Secret Key)") + print(" 2. Неверный endpoint URL") + print(" 3. Проблемы с сетью") + print(" 4. Неверный регион (попробуйте изменить region_name)") + return False + + +if __name__ == "__main__": + result = asyncio.run(test_s3_connection()) + sys.exit(0 if result else 1) -- 2.49.1 From 0e2aef8c03a0c6f80f9767563794931b6fb85bc2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 24 Jan 2026 01:23:35 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализованы методы для добавления связи между постами и сообщениями в `PostRepository` и `AsyncBotDB`. - Обновлены обработчики публикации постов для корректной работы с медиагруппами, включая удаление и уведомление авторов. - Улучшена логика обработки сообщений в `AlbumMiddleware` для более эффективного сбора медиагрупп. - Обновлены тесты для проверки нового функционала и обработки ошибок. --- database/async_db.py | 8 + database/repositories/post_repository.py | 37 ++++- helper_bot/handlers/callback/services.py | 144 +++++++++++------- .../handlers/private/private_handlers.py | 9 +- helper_bot/handlers/private/services.py | 47 +++--- helper_bot/middlewares/album_middleware.py | 39 ++--- helper_bot/utils/helper_func.py | 53 +++---- 7 files changed, 199 insertions(+), 138 deletions(-) diff --git a/database/async_db.py b/database/async_db.py index b48f689..035b6e1 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -139,6 +139,10 @@ class AsyncBotDB: """Добавление контента поста.""" return await self.factory.posts.add_post_content(post_id, message_id, content_name, content_type) + async def add_message_link(self, post_id: int, message_id: int) -> bool: + """Добавляет связь между post_id и message_id в таблицу message_link_to_content.""" + return await self.factory.posts.add_message_link(post_id, message_id) + async def get_post_content_from_telegram_by_last_id(self, last_post_id: int) -> List[Tuple[str, str]]: """Получает контент поста по helper_text_message_id.""" return await self.factory.posts.get_post_content_by_helper_id(last_post_id) @@ -175,6 +179,10 @@ class AsyncBotDB: """Получает ID сообщений по helper_text_message_id.""" return await self.factory.posts.get_post_ids_by_helper_id(last_post_id) + async def get_post_ids_by_helper_id(self, helper_message_id: int) -> List[int]: + """Алиас для get_post_ids_from_telegram_by_last_id (используется callback-сервисом).""" + return await self.get_post_ids_from_telegram_by_last_id(helper_message_id) + async def get_author_id_by_message_id(self, message_id: int) -> Optional[int]: """Получает ID автора по message_id.""" return await self.factory.posts.get_author_id_by_message_id(message_id) diff --git a/database/repositories/post_repository.py b/database/repositories/post_repository.py index fe183ae..79627a2 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -27,10 +27,22 @@ class PostRepository(DatabaseConnection): # Добавляем поле published_message_id если его нет (для существующих БД) try: - await self._execute_query('ALTER TABLE post_from_telegram_suggest ADD COLUMN published_message_id INTEGER') - except Exception: - # Поле уже существует, игнорируем ошибку - pass + 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 = ''' @@ -85,14 +97,15 @@ class PostRepository(DatabaseConnection): # Преобразуем 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 INTO post_from_telegram_suggest (message_id, text, author_id, created_at, status, is_anonymous) + 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}") + 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 сообщения.""" @@ -188,6 +201,18 @@ class PostRepository(DatabaseConnection): 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 = """ diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 1184ab9..671c1b5 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -54,7 +54,12 @@ class PostPublishService: @track_errors("post_publish_service", "publish_post") async def publish_post(self, call: CallbackQuery) -> None: """Основной метод публикации поста""" - # Проверяем, является ли сообщение частью медиагруппы + # Проверяем, является ли сообщение helper-сообщением медиагруппы + if call.message.text == CONTENT_TYPE_MEDIA_GROUP: + await self._publish_media_group(call) + return + + # Проверяем, является ли сообщение частью медиагруппы (для обратной совместимости) if call.message.media_group_id: await self._publish_media_group(call) return @@ -280,44 +285,42 @@ class PostPublishService: @track_media_processing("media_group") async def _publish_media_group(self, call: CallbackQuery) -> None: """Публикация медиагруппы""" - logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}") try: - # call.message.message_id - это ID helper сообщения helper_message_id = call.message.message_id - # Получаем контент медиагруппы по helper_message_id - logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}") + media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id) + if not media_group_message_ids: + logger.error(f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}") + raise PublishError("Не найдены message_id медиагруппы в базе данных") + post_content = await self.db.get_post_content_by_helper_id(helper_message_id) if not post_content: - logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}") + logger.error(f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}") raise PublishError("Контент медиагруппы не найден в базе данных") - # Получаем сырой текст и is_anonymous по helper_message_id - logger.debug(f"Получаю текст и is_anonymous поста для helper_message_id: {helper_message_id}") raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_helper_id(helper_message_id) if raw_text is None: raw_text = "" - logger.debug(f"Текст поста получен: {'пустой' if not raw_text else f'длина: {len(raw_text)} символов'}, is_anonymous={is_anonymous}") - # Получаем ID автора по helper_message_id - logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}") author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id) if not author_id: - logger.error(f"Автор не найден для медиагруппы {helper_message_id}") + logger.error(f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}") raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}") - logger.debug(f"ID автора получен: {author_id}") - # Получаем данные автора user = await self.db.get_user_by_id(author_id) if not user: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") - # Формируем финальный текст с учетом is_anonymous formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous) - logger.debug(f"Сформирован финальный текст: {'пустой' if not formatted_text else f'длина: {len(formatted_text)} символов'}") - # Отправляем медиагруппу в канал - logger.info(f"Отправляю медиагруппу в канал {self.main_public}") + try: + await self._get_bot(call.message).delete_messages( + chat_id=self.group_for_posts, + message_ids=media_group_message_ids + ) + except Exception as e: + logger.warning(f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}") + sent_messages = await send_media_group_to_channel( bot=self._get_bot(call.message), chat_id=self.main_public, @@ -326,31 +329,49 @@ class PostPublishService: s3_storage=self.s3_storage ) - # Получаем оригинальные message_id из медиагруппы - original_message_ids = await self.db.get_post_ids_from_telegram_by_last_id(helper_message_id) - logger.debug(f"Получены оригинальные message_id медиагруппы: {original_message_ids}") - - # Сохраняем published_message_id для каждого сообщения медиагруппы - if len(sent_messages) == len(original_message_ids): - for i, original_message_id in enumerate(original_message_ids): + if len(sent_messages) == len(media_group_message_ids): + for i, original_message_id in enumerate(media_group_message_ids): published_message_id = sent_messages[i].message_id - await self.db.update_published_message_id( - original_message_id=original_message_id, - published_message_id=published_message_id - ) - # Сохраняем медиафайл из опубликованного сообщения (используем уже сохраненный файл) - await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id) - logger.debug(f"Сохранен published_message_id: {original_message_id} -> {published_message_id}") + try: + await self.db.update_published_message_id( + original_message_id=original_message_id, + published_message_id=published_message_id + ) + await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id) + except Exception as e: + logger.warning(f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}") else: - logger.warning(f"Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(original_message_ids)})") + logger.warning(f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})") await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved") - logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}") - await self._delete_media_group_and_notify_author(call, author_id) - logger.info(f'Медиагруппа опубликована в канале {self.main_public}, опубликовано сообщений: {len(sent_messages)}.') + + # Удаляем helper сообщение - это критично, делаем это всегда + try: + await self._get_bot(call.message).delete_message( + chat_id=self.group_for_posts, + message_id=helper_message_id + ) + except Exception as e: + logger.warning(f"_publish_media_group: Ошибка при удалении helper сообщения: {e}") + + try: + await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + logger.warning(f"_publish_media_group: Пользователь {author_id} заблокировал бота") + raise UserBlockedBotError("Пользователь заблокировал бота") + logger.error(f"_publish_media_group: Ошибка при отправке уведомления автору: {e}") except Exception as e: - logger.error(f"Ошибка при публикации медиагруппы: {e}") + logger.error(f"_publish_media_group: Ошибка при публикации медиагруппы: {e}") + # Пытаемся удалить helper сообщение даже при ошибке + try: + await self._get_bot(call.message).delete_message( + chat_id=self.group_for_posts, + message_id=call.message.message_id + ) + except Exception as delete_error: + logger.warning(f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}") raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}") @track_time("decline_post", "post_publish_service") @@ -399,27 +420,32 @@ class PostPublishService: @track_media_processing("media_group") async def _decline_media_group(self, call: CallbackQuery) -> None: """Отклонение медиагруппы""" - await self.db.update_status_for_media_group_by_helper_id(call.message.message_id, "declined") + helper_message_id = call.message.message_id + + await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "declined") - post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) - message_ids = post_ids.copy() - message_ids.append(call.message.message_id) - logger.debug(f"Получены ID сообщений для удаления: {message_ids}") + media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id) - author_id = await self._get_author_id_for_media_group(call.message.message_id) - logger.debug(f"ID автора медиагруппы получен: {author_id}") + message_ids_to_delete = media_group_message_ids.copy() + message_ids_to_delete.append(helper_message_id) - logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}") - await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + author_id = await self._get_author_id_for_media_group(helper_message_id) + + try: + await self._get_bot(call.message).delete_messages( + chat_id=self.group_for_posts, + message_ids=message_ids_to_delete + ) + except Exception as e: + logger.warning(f"_decline_media_group: Ошибка при удалении сообщений: {e}") try: - logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}") await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) except Exception as e: if str(e) == ERROR_BOT_BLOCKED: - logger.warning(f"Пользователь {author_id} заблокировал бота") + logger.warning(f"_decline_media_group: Пользователь {author_id} заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота") - logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}") + logger.error(f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}") raise @track_time("_get_author_id", "post_publish_service") @@ -477,12 +503,15 @@ class PostPublishService: @track_errors("post_publish_service", "_delete_media_group_and_notify_author") @track_media_processing("media_group") async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: - """Удаление медиагруппы и уведомление автора""" - post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) + """Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)""" + helper_message_id = call.message.message_id + + media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id) - #message_ids = post_ids.copy() - post_ids.append(call.message.message_id) - await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_ids) + message_ids_to_delete = media_group_message_ids.copy() + message_ids_to_delete.append(helper_message_id) + + await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids_to_delete) try: await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) except Exception as e: @@ -538,7 +567,12 @@ class BanService: @db_query_time("ban_user_from_post", "users", "mixed") async def ban_user_from_post(self, call: CallbackQuery) -> None: """Бан пользователя за спам""" - author_id = await self.db.get_author_id_by_message_id(call.message.message_id) + # Если это helper-сообщение медиагруппы, используем специальный метод + if call.message.text == CONTENT_TYPE_MEDIA_GROUP: + author_id = await self.db.get_author_id_by_helper_message_id(call.message.message_id) + else: + author_id = await self.db.get_author_id_by_message_id(call.message.message_id) + if not author_id: raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}") diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index 05a21b9..1dace7d 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -51,9 +51,8 @@ class PrivateHandlers: self.post_service = PostService(db, settings, s3_storage) 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 @@ -158,12 +157,10 @@ class PrivateHandlers: @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""" - # Post service operations with metrics await self.user_service.update_user_activity(message.from_user.id) - await self.user_service.log_user_message(message) + if message.media_group_id is None: + await self.user_service.log_user_message(message) await self.post_service.process_post(message, album) - - # Send success message and return to start 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) diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 4341f13..ec233c3 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -323,7 +323,6 @@ class PostService: @track_media_processing("media_group") async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None: """Handle media group post submission""" - #TODO: Мне кажется тут какая-то дичь с одинаковыми переменными, в которых post_caption никуда не ведет post_caption = " " raw_caption = "" @@ -331,12 +330,17 @@ class PostService: raw_caption = album[0].caption or "" post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) - # Определяем анонимность на основе сырого caption is_anonymous = determine_anonymity(raw_caption) + media_group = await prepare_media_group_from_middlewares(album, post_caption) + + media_group_message_ids = await send_media_group_message_to_private_chat( + self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage + ) + + main_post_id = media_group_message_ids[-1] - # Создаем основной пост для медиагруппы main_post = TelegramPost( - message_id=message.message_id, # ID основного сообщения медиагруппы + message_id=main_post_id, text=raw_caption, author_id=message.from_user.id, created_at=int(datetime.now().timestamp()), @@ -344,32 +348,32 @@ class PostService: ) await self.db.add_post(main_post) - # Отправляем медиагруппу в группу для модерации - media_group = await prepare_media_group_from_middlewares(album, post_caption) - media_group_message_id = await send_media_group_message_to_private_chat( - self.settings.group_for_posts, message, media_group, self.db, main_post.message_id, self.s3_storage - ) + for msg_id in media_group_message_ids: + await self.db.add_message_link(main_post_id, msg_id) await asyncio.sleep(0.2) - # Создаем helper сообщение с кнопками markup = get_reply_keyboard_for_post() - help_message = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА") + helper_message = await send_text_message( + self.settings.group_for_posts, + message, + "^", + markup + ) + helper_message_id = helper_message.message_id - # Создаем helper пост и связываем его с основным helper_post = TelegramPost( - message_id=help_message.message_id, # ID helper сообщения - text="^", # Специальный маркер для медиагруппы + message_id=helper_message_id, + text="^", author_id=message.from_user.id, - helper_text_message_id=main_post.message_id, # Ссылка на основной пост + helper_text_message_id=main_post_id, created_at=int(datetime.now().timestamp()) ) await self.db.add_post(helper_post) - # Обновляем основной пост, чтобы он ссылался на helper await self.db.update_helper_message( - message_id=main_post.message_id, - helper_message_id=help_message.message_id + message_id=main_post_id, + helper_message_id=helper_message_id ) @track_time("process_post", "post_service") @@ -378,13 +382,8 @@ class PostService: async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None: """Process post based on content type""" first_name = get_first_name(message) - # TODO: Бесит меня этот функционал + if message.media_group_id is not None: - safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма" - await send_text_message( - self.settings.group_for_logs, message, - f'Закинул медиагруппу, пользователь: имя - {first_name}, ник - {safe_username}' - ) await self.handle_media_group_post(message, album, first_name) return diff --git a/helper_bot/middlewares/album_middleware.py b/helper_bot/middlewares/album_middleware.py index 65f1743..a94022a 100644 --- a/helper_bot/middlewares/album_middleware.py +++ b/helper_bot/middlewares/album_middleware.py @@ -11,7 +11,7 @@ class AlbumMiddleware(BaseMiddleware): Собирает все сообщения одной медиа группы и передает их как album в data. """ - def __init__(self, latency: Union[int, float] = 0.01): + def __init__(self, latency: Union[int, float] = 5.0): """ Инициализация middleware. @@ -45,38 +45,43 @@ class AlbumMiddleware(BaseMiddleware): """ Основная логика middleware. + Собирает все сообщения медиагруппы и обрабатывает только последнее сообщение + после завершения сбора всех сообщений. + Args: handler: Обработчик события event: Событие (сообщение) data: Данные для передачи в обработчик - + Returns: Результат выполнения обработчика """ - # Если у события нет media_group_id, передаем его обработчику сразу if not event.media_group_id: return await handler(event, data) - # Собираем сообщения одной медиа группы - total_before = self.collect_album_messages(event) + media_group_id = event.media_group_id + message_id = event.message_id + + if media_group_id not in self.album_data: + self.album_data[media_group_id] = {"messages": []} + + self.album_data[media_group_id]["messages"].append(event) + count_before = len(self.album_data[media_group_id]["messages"]) - # Ждем указанный период для сбора всех сообщений await asyncio.sleep(self.latency) - # Проверяем количество сообщений после задержки - total_after = len(self.album_data[event.media_group_id]["messages"]) - - # Если за время задержки добавились новые сообщения, выходим - if total_before != total_after: + count_after = len(self.album_data[media_group_id]["messages"]) + if count_before != count_after: return - # Сортируем сообщения по message_id и добавляем в data - album_messages = self.album_data[event.media_group_id]["messages"] + album_messages = self.album_data[media_group_id]["messages"] album_messages.sort(key=lambda x: x.message_id) + last_message_id = album_messages[-1].message_id + + if message_id != last_message_id: + return + data["album"] = album_messages + del self.album_data[media_group_id] - # Удаляем медиа группу из отслеживания для освобождения памяти - del self.album_data[event.media_group_id] - - # Вызываем оригинальный обработчик события return await handler(event, data) diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 360c1b7..8159de9 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -369,9 +369,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа") return False - # Используем переданный main_post_id или ID последнего сообщения post_id = main_post_id or sent_message[-1].message_id - logger.debug(f"add_in_db_media_mediagroup: Обрабатываю медиагруппу из {len(sent_message)} сообщений, post_id: {post_id}") processed_count = 0 failed_count = 0 @@ -381,7 +379,6 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: content_type = None file_id = None - # Определяем тип контента и file_id if message.photo: content_type = 'photo' file_id = message.photo[-1].file_id @@ -407,54 +404,40 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: failed_count += 1 continue - logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}") - - # Получаем s3_storage если не передан if s3_storage is None: bdf = get_global_instance() s3_storage = bdf.get_s3_storage() - # Скачиваем файл (в S3 или на локальный диск) file_path = await download_file(message, file_id=file_id, content_type=content_type, s3_storage=s3_storage) if not file_path: logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}") failed_count += 1 continue - # Добавляем в базу данных - # Для медиагруппы используем post_id (основной пост) как message_id для контента, - # так как FOREIGN KEY требует существования message_id в post_from_telegram_suggest success = await bot_db.add_post_content(post_id, post_id, file_path, content_type) if not success: logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}") - # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3) if file_path.startswith('files/'): try: os.remove(file_path) - logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД") except Exception as e: logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}") failed_count += 1 continue processed_count += 1 - logger.debug(f"add_in_db_media_mediagroup: Успешно обработано сообщение {i+1}/{len(sent_message)}") except Exception as e: logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}") failed_count += 1 continue - processing_time = time.time() - start_time - if processed_count == 0: logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}") return False if failed_count > 0: logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}") - else: - logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с") return failed_count == 0 @@ -556,22 +539,32 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any, s3_storage = @track_media_processing("media_group") @db_query_time("send_media_group_message_to_private_chat", "posts", "insert") async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, - media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> int: - sent_message = await message.bot.send_media_group( + media_group: List, bot_db: Any, main_post_id: Optional[int] = None, s3_storage=None) -> List[int]: + """ + Отправляет медиагруппу в чат и возвращает все message_id отправленных сообщений. + + Args: + chat_id: ID чата для отправки + message: Оригинальное сообщение от пользователя + media_group: Список InputMedia объектов + bot_db: Экземпляр базы данных + main_post_id: ID основного поста в БД (опционально) + s3_storage: S3StorageService для сохранения медиа + + Returns: + List[int]: Список всех message_id отправленных сообщений медиагруппы + """ + sent_messages = await message.bot.send_media_group( chat_id=chat_id, media=media_group, ) - post = TelegramPost( - message_id=sent_message[-1].message_id, - text=sent_message[-1].caption or "", - author_id=message.from_user.id, - created_at=int(datetime.now().timestamp()) - ) - await bot_db.add_post(post) - # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю - asyncio.create_task(_save_media_group_background(sent_message, bot_db, main_post_id, s3_storage)) - message_id = sent_message[-1].message_id - return message_id + + sent_message_ids = [msg.message_id for msg in sent_messages] + main_message_id = sent_message_ids[-1] + + asyncio.create_task(_save_media_group_background(sent_messages, bot_db, main_message_id, s3_storage)) + + return sent_message_ids @track_time("send_media_group_to_channel", "helper_func") @track_errors("helper_func", "send_media_group_to_channel") -- 2.49.1 From 5a90591564952c457d3142b5325508529c5cf168 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 24 Jan 2026 01:35:36 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B0=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20?= =?UTF-8?q?=D0=B2=20`PrivateHandlers`=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=20`AlbumMiddleware`=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализована фоновая обработка медиагрупп, позволяющая пользователю получать ответ сразу, пока происходит сбор сообщений. - Введен класс `AlbumGetter` для получения полной медиагруппы с использованием событий. - Обновлены методы в `AlbumMiddleware` для поддержки нового функционала и улучшения логики обработки сообщений. --- .../handlers/private/private_handlers.py | 44 +++++-- helper_bot/middlewares/album_middleware.py | 118 +++++++++++++++--- 2 files changed, 135 insertions(+), 27 deletions(-) diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index 1dace7d..1974cee 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -157,14 +157,42 @@ class PrivateHandlers: @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""" - 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) - await self.post_service.process_post(message, album) - 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") + + if album_getter and message.media_group_id: + # Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне + 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"]) + + # В фоне ждем полную медиагруппу и обрабатываем пост + async def process_media_group_background(): + try: + # Ждем полную медиагруппу + full_album = await album_getter.get_album(timeout=10.0) + if not full_album: + return + + # Обрабатываем пост с полной медиагруппой + await self.user_service.update_user_activity(message.from_user.id) + await self.post_service.process_post(message, full_album) + except Exception as e: + from logs.custom_logger import logger + logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}") + + asyncio.create_task(process_media_group_background()) + else: + # Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно + 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) + await self.post_service.process_post(message, album) + 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"]) @error_handler @track_errors("private_handlers", "stickers") diff --git a/helper_bot/middlewares/album_middleware.py b/helper_bot/middlewares/album_middleware.py index a94022a..e190955 100644 --- a/helper_bot/middlewares/album_middleware.py +++ b/helper_bot/middlewares/album_middleware.py @@ -1,14 +1,42 @@ import asyncio -from typing import Any, Dict, Union, List +from typing import Any, Dict, Union, List, Optional from aiogram import BaseMiddleware from aiogram.types import Message +class AlbumGetter: + """Вспомогательный класс для получения полной медиагруппы из middleware""" + + 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]]: + """ + Ждет полную медиагруппу и возвращает ее. + + Args: + timeout: Максимальное время ожидания в секундах + + Returns: + Список сообщений медиагруппы или None при таймауте + """ + 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): @@ -20,7 +48,8 @@ class AlbumMiddleware(BaseMiddleware): """ super().__init__() self.latency = latency - self.album_data: Dict[str, Dict[str, List[Message]]] = {} + # Храним данные медиагруппы: messages, event для уведомления, task для сбора + self.album_data: Dict[str, Dict[str, Any]] = {} def collect_album_messages(self, event: Message) -> int: """ @@ -41,12 +70,53 @@ class AlbumMiddleware(BaseMiddleware): self.album_data[event.media_group_id]["messages"].append(event) 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: """ Основная логика middleware. - Собирает все сообщения медиагруппы и обрабатывает только последнее сообщение - после завершения сбора всех сообщений. + Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы. + Для обычных сообщений: сразу вызывает handler. Args: handler: Обработчик события @@ -62,26 +132,36 @@ class AlbumMiddleware(BaseMiddleware): 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: - self.album_data[media_group_id] = {"messages": []} + 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) - count_before = len(self.album_data[media_group_id]["messages"]) - await asyncio.sleep(self.latency) - - count_after = len(self.album_data[media_group_id]["messages"]) - if count_before != count_after: + # Обрабатываем только первое сообщение медиагруппы + if not is_first_message: + # Для остальных сообщений просто возвращаемся, не вызывая handler return - album_messages = self.album_data[media_group_id]["messages"] - album_messages.sort(key=lambda x: x.message_id) - last_message_id = album_messages[-1].message_id - - if message_id != last_message_id: - return - - data["album"] = album_messages - del self.album_data[media_group_id] + # Передаем объект-геттер в 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) -- 2.49.1 From d2d7c83575ff2662c0de9e25ca283303158ba857 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 25 Jan 2026 16:07:27 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20Python=20=D0=B4=D0=BE=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=203.11.9=20=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B2=20Dockerfile=20=D0=B8=20pyproject.t?= =?UTF-8?q?oml.=20=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B=20RATE=5FLIMITING=5FSOLUTION.md=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?rate=20limiting.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=BA=20=D0=B1=D0=B8?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D0=B0=D0=BC=20=D0=B2?= =?UTF-8?q?=20Dockerfile=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20Pyt?= =?UTF-8?q?hon.=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B2=D1=81=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B2=D1=81=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D1=8F=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/architecture.md | 88 +++ .cursor/rules/code-style.md | 106 ++++ .cursor/rules/database-patterns.md | 117 ++++ .cursor/rules/dependencies-and-utils.md | 172 ++++++ .cursor/rules/error-handling.md | 156 ++++++ .cursor/rules/handlers-patterns.md | 215 +++++++ .cursor/rules/middleware-patterns.md | 109 ++++ .cursor/rules/testing.md | 197 +++++++ .python-version | 2 +- Dockerfile | 6 +- RATE_LIMITING_SOLUTION.md | 171 ------ docs/IMPROVEMENTS.md | 710 ++++++++++++++++++++++++ docs/OPERATIONS.md | 309 +++++++++++ pyproject.toml | 2 +- test_rate_limiting.py | 150 ----- tests/test_blacklist_repository.py | 16 +- tests/test_improved_media_processing.py | 30 +- tests/test_post_repository.py | 110 ++-- tests/test_post_service.py | 30 +- tests/test_refactored_admin_handlers.py | 2 +- tests/test_utils.py | 35 +- 21 files changed, 2324 insertions(+), 409 deletions(-) create mode 100644 .cursor/rules/architecture.md create mode 100644 .cursor/rules/code-style.md create mode 100644 .cursor/rules/database-patterns.md create mode 100644 .cursor/rules/dependencies-and-utils.md create mode 100644 .cursor/rules/error-handling.md create mode 100644 .cursor/rules/handlers-patterns.md create mode 100644 .cursor/rules/middleware-patterns.md create mode 100644 .cursor/rules/testing.md delete mode 100644 RATE_LIMITING_SOLUTION.md create mode 100644 docs/IMPROVEMENTS.md create mode 100644 docs/OPERATIONS.md delete mode 100644 test_rate_limiting.py diff --git a/.cursor/rules/architecture.md b/.cursor/rules/architecture.md new file mode 100644 index 0000000..4e8dd71 --- /dev/null +++ b/.cursor/rules/architecture.md @@ -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` diff --git a/.cursor/rules/code-style.md b/.cursor/rules/code-style.md new file mode 100644 index 0000000..836bff1 --- /dev/null +++ b/.cursor/rules/code-style.md @@ -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.""" + ... +``` diff --git a/.cursor/rules/database-patterns.md b/.cursor/rules/database-patterns.md new file mode 100644 index 0000000..05918c3 --- /dev/null +++ b/.cursor/rules/database-patterns.md @@ -0,0 +1,117 @@ +--- +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` для доступа к репозиториям + +## Миграции + +- SQL миграции в `database/schema.sql` +- Python скрипты для миграций в `scripts/` +- Всегда проверяйте существование таблиц перед созданием: `CREATE TABLE IF NOT EXISTS` diff --git a/.cursor/rules/dependencies-and-utils.md b/.cursor/rules/dependencies-and-utils.md new file mode 100644 index 0000000..c2cc1d1 --- /dev/null +++ b/.cursor/rules/dependencies-and-utils.md @@ -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. **Логируйте важные операции** с внешними сервисами diff --git a/.cursor/rules/error-handling.md b/.cursor/rules/error-handling.md new file mode 100644 index 0000000..8ca866c --- /dev/null +++ b/.cursor/rules/error-handling.md @@ -0,0 +1,156 @@ +--- +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()` - отладочная информация +- `logger.info()` - информационные сообщения о работе +- `logger.warning()` - предупреждения о потенциальных проблемах +- `logger.error()` - ошибки, требующие внимания +- `logger.critical()` - критические ошибки + +## Метрики ошибок + +Декоратор `@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 diff --git a/.cursor/rules/handlers-patterns.md b/.cursor/rules/handlers-patterns.md new file mode 100644 index 0000000..f5d4feb --- /dev/null +++ b/.cursor/rules/handlers-patterns.md @@ -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` для автоматической обработки (если есть) diff --git a/.cursor/rules/middleware-patterns.md b/.cursor/rules/middleware-patterns.md new file mode 100644 index 0000000..d9aaa25 --- /dev/null +++ b/.cursor/rules/middleware-patterns.md @@ -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()`** для локальной регистрации на уровне модуля diff --git a/.cursor/rules/testing.md b/.cursor/rules/testing.md new file mode 100644 index 0000000..b5fa7d9 --- /dev/null +++ b/.cursor/rules/testing.md @@ -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. **Тестируйте граничные случаи** и ошибки diff --git a/.python-version b/.python-version index 1635d0f..2419ad5 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9.6 +3.11.9 diff --git a/Dockerfile b/Dockerfile index ceeccb5..0c36fe8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ########################################### # Этап 1: Сборщик (Builder) ########################################### -FROM python:3.9-alpine as builder +FROM python:3.11.9-alpine as builder # Устанавливаем инструменты для компиляции + linux-headers для psutil RUN apk add --no-cache \ @@ -21,7 +21,7 @@ RUN pip install --no-cache-dir --target /install -r requirements.txt ########################################### # Этап 2: Финальный образ (Runtime) ########################################### -FROM python:3.9-alpine as runtime +FROM python:3.11.9-alpine as runtime # Минимальные рантайм-зависимости RUN apk add --no-cache \ @@ -34,7 +34,7 @@ RUN addgroup -g 1001 deploy && adduser -D -u 1001 -G deploy deploy WORKDIR /app # Копируем зависимости -COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.9/site-packages +COPY --from=builder --chown=1001:1001 /install /usr/local/lib/python3.11/site-packages # Создаем структуру папок RUN mkdir -p database logs voice_users && \ diff --git a/RATE_LIMITING_SOLUTION.md b/RATE_LIMITING_SOLUTION.md deleted file mode 100644 index ee41011..0000000 --- a/RATE_LIMITING_SOLUTION.md +++ /dev/null @@ -1,171 +0,0 @@ -# Решение проблемы Flood Control в Telegram Bot - -## Проблема - -В логах бота наблюдались ошибки типа: -``` -Flood control exceeded on method 'SendVoice' in chat 1322897572. Retry in 3 seconds. -``` - -Эти ошибки возникают при превышении лимитов Telegram Bot API: -- Не более 30 сообщений в секунду от одного бота глобально -- Не более 1 сообщения в секунду в один чат -- Дополнительные ограничения для разных типов сообщений - -## Решение - -Реализована комплексная система rate limiting, включающая: - -### 1. Основные компоненты - -#### `rate_limiter.py` -- **ChatRateLimiter**: Ограничивает скорость отправки сообщений для конкретного чата -- **GlobalRateLimiter**: Глобальные ограничения для всех чатов -- **RetryHandler**: Обработка повторных попыток с экспоненциальной задержкой -- **TelegramRateLimiter**: Основной класс, объединяющий все компоненты - -#### `rate_limit_monitor.py` -- **RateLimitMonitor**: Мониторинг и статистика rate limiting -- Отслеживание успешных/неудачных запросов -- Анализ ошибок и производительности -- Статистика по чатам - -#### `rate_limit_config.py` -- Конфигурации для разных окружений (development, production, strict) -- Адаптивные настройки на основе уровня ошибок -- Настройки для разных типов сообщений - -#### `rate_limit_middleware.py` -- Middleware для автоматического применения rate limiting -- Перехват всех исходящих сообщений -- Прозрачная интеграция с существующим кодом - -### 2. Ключевые особенности - -#### Rate Limiting -- **Настраиваемая скорость**: 0.5 сообщений в секунду на чат (по умолчанию) -- **Burst protection**: Максимум 2 сообщения подряд -- **Глобальные ограничения**: 10 сообщений в секунду глобально -- **Адаптивные задержки**: Увеличение задержек при ошибках - -#### Retry Mechanism -- **Экспоненциальная задержка**: Увеличение времени ожидания при повторных попытках -- **Максимальные ограничения**: Ограничение максимального времени ожидания -- **Умная обработка ошибок**: Разные стратегии для разных типов ошибок - -#### Мониторинг -- **Детальная статистика**: Отслеживание всех запросов и ошибок -- **Анализ производительности**: Процент успеха, время ожидания, активность -- **Административные команды**: `/ratelimit_stats`, `/ratelimit_errors`, `/reset_ratelimit_stats` - -### 3. Интеграция - -#### Обновленные функции -```python -# helper_func.py -async def send_voice_message(chat_id, message, voice, markup=None): - from .rate_limiter import send_with_rate_limit - - async def _send_voice(): - if markup is None: - return await message.bot.send_voice(chat_id=chat_id, voice=voice) - else: - return await message.bot.send_voice(chat_id=chat_id, voice=voice, reply_markup=markup) - - return await send_with_rate_limit(_send_voice, chat_id) -``` - -#### Middleware -```python -# voice_handler.py -from helper_bot.middlewares.rate_limit_middleware import MessageSendMiddleware - -def _setup_middleware(self): - self.router.message.middleware(DependenciesMiddleware()) - self.router.message.middleware(BlacklistMiddleware()) - self.router.message.middleware(MessageSendMiddleware()) # Новый middleware -``` - -### 4. Конфигурация - -#### Production настройки (по умолчанию) -```python -PRODUCTION_CONFIG = RateLimitSettings( - messages_per_second=0.5, # 1 сообщение каждые 2 секунды - burst_limit=2, # Максимум 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 -) -``` - -#### Адаптивная конфигурация -Система автоматически ужесточает ограничения при высоком уровне ошибок: -- При >10% ошибок: уменьшение скорости в 2 раза -- При <1% ошибок: увеличение скорости на 20% - -### 5. Мониторинг и администрирование - -#### Команды для администраторов -- `/ratelimit_stats` - Показать статистику rate limiting -- `/ratelimit_errors` - Показать недавние ошибки -- `/reset_ratelimit_stats` - Сбросить статистику - -#### Пример вывода статистики -``` -📊 Статистика Rate Limiting - -🔢 Общая статистика: -• Всего запросов: 1250 -• Процент успеха: 98.4% -• Процент ошибок: 1.6% -• Запросов в минуту: 12.5 -• Среднее время ожидания: 1.2с -• Активных чатов: 45 -• Ошибок за час: 3 - -🔍 Детальная статистика: -• Успешных запросов: 1230 -• Неудачных запросов: 20 -• RetryAfter ошибок: 15 -• Других ошибок: 5 -``` - -### 6. Тестирование - -Создан полный набор тестов в `test_rate_limiter.py`: -- Тесты всех компонентов -- Интеграционные тесты -- Тесты конфигурации -- Тесты мониторинга - -Запуск тестов: -```bash -pytest tests/test_rate_limiter.py -v -``` - -### 7. Преимущества решения - -1. **Предотвращение ошибок**: Автоматическое соблюдение лимитов API -2. **Прозрачность**: Минимальные изменения в существующем коде -3. **Мониторинг**: Полная видимость производительности -4. **Адаптивность**: Автоматическая настройка под нагрузку -5. **Надежность**: Умная обработка ошибок и повторных попыток -6. **Масштабируемость**: Поддержка множества чатов - -### 8. Рекомендации по использованию - -1. **Мониторинг**: Регулярно проверяйте статистику через `/ratelimit_stats` -2. **Настройка**: При необходимости корректируйте конфигурацию под ваши нужды -3. **Алерты**: Настройте уведомления при высоком проценте ошибок -4. **Тестирование**: Проверяйте работу в тестовой среде перед продакшеном - -### 9. Будущие улучшения - -- Интеграция с системой метрик (Prometheus/Grafana) -- Автоматическое масштабирование ограничений -- A/B тестирование разных конфигураций -- Интеграция с системой алертов diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md new file mode 100644 index 0000000..510201e --- /dev/null +++ b/docs/IMPROVEMENTS.md @@ -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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..84f2a1e --- /dev/null +++ b/docs/OPERATIONS.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml index e2863bd..8689105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "telegram-helper-bot" version = "1.0.0" description = "Telegram bot with monitoring and metrics" -requires-python = ">=3.9" +requires-python = ">=3.11" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/test_rate_limiting.py b/test_rate_limiting.py deleted file mode 100644 index 084f4f3..0000000 --- a/test_rate_limiting.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -""" -Скрипт для тестирования rate limiting решения -""" -import asyncio -import time -from unittest.mock import AsyncMock, MagicMock -from aiogram.types import Message, User, Chat - -from helper_bot.utils.rate_limiter import send_with_rate_limit -from helper_bot.utils.rate_limit_monitor import rate_limit_monitor, get_rate_limit_summary - - -async def test_rate_limiting(): - """Тестирует rate limiting с имитацией отправки сообщений""" - - print("🚀 Начинаем тестирование rate limiting...") - - # Создаем мок объекты - mock_bot = MagicMock() - mock_user = User(id=123, is_bot=False, first_name="Test") - mock_chat = Chat(id=456, type="private") - - # Создаем Message с bot в конструкторе - mock_message = Message( - message_id=1, - date=int(time.time()), - chat=mock_chat, - from_user=mock_user, - content_type="text", - bot=mock_bot - ) - - # Настраиваем мок для send_voice - mock_bot.send_voice = AsyncMock(return_value=MagicMock(message_id=1)) - - # Функция для отправки голосового сообщения - async def send_voice_test(): - return await mock_bot.send_voice( - chat_id=mock_chat.id, - voice="test_voice_id" - ) - - print("📊 Отправляем 5 сообщений подряд...") - - # Отправляем несколько сообщений подряд - start_time = time.time() - for i in range(5): - print(f" Отправка сообщения {i+1}/5...") - try: - result = await send_with_rate_limit(send_voice_test, mock_chat.id) - print(f" ✅ Сообщение {i+1} отправлено успешно") - except Exception as e: - print(f" ❌ Ошибка при отправке сообщения {i+1}: {e}") - - end_time = time.time() - total_time = end_time - start_time - - print(f"\n⏱️ Общее время выполнения: {total_time:.2f} секунд") - print(f"📈 Среднее время на сообщение: {total_time/5:.2f} секунд") - - # Показываем статистику - print("\n📊 Статистика rate limiting:") - summary = get_rate_limit_summary() - for key, value in summary.items(): - if isinstance(value, float): - print(f" {key}: {value:.2f}") - else: - print(f" {key}: {value}") - - # Показываем детальную статистику - print("\n🔍 Детальная статистика:") - global_stats = rate_limit_monitor.get_global_stats() - print(f" Всего запросов: {global_stats.total_requests}") - print(f" Успешных: {global_stats.successful_requests}") - print(f" Неудачных: {global_stats.failed_requests}") - print(f" Процент успеха: {global_stats.success_rate:.1%}") - print(f" Среднее время ожидания: {global_stats.average_wait_time:.2f}с") - - # Проверяем что rate limiting работает - if total_time > 8: # Должно занять больше 8 секунд (5 сообщений * 1.6с минимум) - print("\n✅ Rate limiting работает корректно - сообщения отправляются с задержкой") - else: - print("\n⚠️ Rate limiting может работать некорректно - сообщения отправлены слишком быстро") - - print("\n🎉 Тестирование завершено!") - - -async def test_error_handling(): - """Тестирует обработку ошибок""" - - print("\n🧪 Тестируем обработку ошибок...") - - # Создаем мок который будет падать с RetryAfter - from aiogram.exceptions import TelegramRetryAfter - - mock_bot = MagicMock() - mock_chat = Chat(id=789, type="private") - - call_count = 0 - async def failing_send(): - nonlocal call_count - call_count += 1 - if call_count <= 2: - raise TelegramRetryAfter( - method=MagicMock(), - message="Flood control exceeded", - retry_after=1 - ) - return MagicMock(message_id=call_count) - - mock_bot.send_voice = failing_send - - print("📤 Отправляем сообщение с имитацией RetryAfter ошибки...") - - start_time = time.time() - try: - result = await send_with_rate_limit(failing_send, mock_chat.id) - end_time = time.time() - print(f"✅ Сообщение отправлено после {call_count} попыток за {end_time - start_time:.2f}с") - except Exception as e: - print(f"❌ Ошибка: {e}") - - print("🎯 Тест обработки ошибок завершен!") - - -async def main(): - """Основная функция""" - print("🔧 Тестирование решения Flood Control") - print("=" * 50) - - # Сбрасываем статистику - rate_limit_monitor.reset_stats() - - # Запускаем тесты - await test_rate_limiting() - await test_error_handling() - - print("\n" + "=" * 50) - print("📋 Итоговая статистика:") - summary = get_rate_limit_summary() - for key, value in summary.items(): - if isinstance(value, float): - print(f" {key}: {value:.2f}") - else: - print(f" {key}: {value}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/test_blacklist_repository.py b/tests/test_blacklist_repository.py index f4de1b7..ae4bc21 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -239,7 +239,10 @@ class TestBlacklistRepository: blacklist_repository._execute_query_with_result.assert_called_once() call_args = blacklist_repository._execute_query_with_result.call_args - assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist LIMIT ?, ?" + # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) + actual_query = ' '.join(call_args[0][0].split()) + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" + assert actual_query == expected_query assert call_args[0][1] == (0, 10) # Проверяем логирование @@ -250,10 +253,10 @@ class TestBlacklistRepository: @pytest.mark.asyncio async def test_get_all_users_no_limit(self, blacklist_repository): """Тест получения всех пользователей без лимитов""" - # Симулируем результат запроса + # Симулируем результат запроса (теперь включает ban_author) mock_rows = [ - (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time())), - (67890, "Постоянный бан", None, int(time.time()) - 86400) + (12345, "Нарушение правил", int(time.time()) + 86400, int(time.time()), 999), + (67890, "Постоянный бан", None, int(time.time()) - 86400, None) ] blacklist_repository._execute_query_with_result.return_value = mock_rows @@ -266,7 +269,10 @@ class TestBlacklistRepository: blacklist_repository._execute_query_with_result.assert_called_once() call_args = blacklist_repository._execute_query_with_result.call_args - assert call_args[0][0] == "SELECT user_id, message_for_user, date_to_unban, created_at FROM blacklist" + # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) + actual_query = ' '.join(call_args[0][0].split()) + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" + assert actual_query == expected_query # Проверяем, что параметры пустые (без лимитов) assert len(call_args[0]) == 1 # Только SQL запрос, без параметров diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py index dba6442..7c2b642 100644 --- a/tests/test_improved_media_processing.py +++ b/tests/test_improved_media_processing.py @@ -258,16 +258,15 @@ class TestSendMediaGroupMessageToPrivateChat: # Мокаем БД mock_db = AsyncMock() - mock_db.add_post = AsyncMock() with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True): - result = await send_media_group_message_to_private_chat( - 100, mock_message, [], mock_db, main_post_id=789 - ) - - assert result == 456 - mock_message.bot.send_media_group.assert_called_once() - mock_db.add_post.assert_called_once() + with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась + result = await send_media_group_message_to_private_chat( + 100, mock_message, [], mock_db, main_post_id=789 + ) + + assert result == [456] # Функция возвращает список message_id + mock_message.bot.send_media_group.assert_called_once() @pytest.mark.asyncio async def test_send_media_group_message_media_processing_fails(self): @@ -285,16 +284,15 @@ class TestSendMediaGroupMessageToPrivateChat: # Мокаем БД mock_db = AsyncMock() - mock_db.add_post = AsyncMock() with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False): - result = await send_media_group_message_to_private_chat( - 100, mock_message, [], mock_db, main_post_id=789 - ) - - assert result == 456 # Функция все равно возвращает message_id - mock_message.bot.send_media_group.assert_called_once() - mock_db.add_post.assert_called_once() + with patch('asyncio.create_task'): # Мокаем create_task, чтобы фоновая задача не выполнялась + result = await send_media_group_message_to_private_chat( + 100, mock_message, [], mock_db, main_post_id=789 + ) + + assert result == [456] # Функция возвращает список message_id + mock_message.bot.send_media_group.assert_called_once() if __name__ == "__main__": diff --git a/tests/test_post_repository.py b/tests/test_post_repository.py index 5030b58..441ec3d 100644 --- a/tests/test_post_repository.py +++ b/tests/test_post_repository.py @@ -62,33 +62,38 @@ class TestPostRepository: @pytest.mark.asyncio async def test_create_tables(self, post_repository): """Тест создания таблиц.""" - # Мокаем _execute_query + # Мокаем _execute_query и _execute_query_with_result post_repository._execute_query = AsyncMock() + post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца await post_repository.create_tables() - # Проверяем, что create_tables вызвался 3 раза (для каждой таблицы) - assert post_repository._execute_query.call_count == 3 + # Проверяем, что create_tables вызвался минимум 3 раза (для каждой таблицы) + # Может быть больше из-за ALTER TABLE и индексов + assert post_repository._execute_query.call_count >= 3 + + # Проверяем, что все нужные таблицы созданы (порядок может быть разным из-за ALTER TABLE) + calls = post_repository._execute_query.call_args_list + all_queries = [call[0][0] for call in calls] # Проверяем создание таблицы постов - calls = post_repository._execute_query.call_args_list - post_table_call = calls[0][0][0] - assert "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest" in post_table_call - assert "message_id INTEGER NOT NULL PRIMARY KEY" in post_table_call - assert "created_at INTEGER NOT NULL" in post_table_call - assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_call - assert "is_anonymous INTEGER" in post_table_call - assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_call + post_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS post_from_telegram_suggest" in q] + assert len(post_table_queries) > 0 + assert "message_id INTEGER NOT NULL PRIMARY KEY" in post_table_queries[0] + assert "created_at INTEGER NOT NULL" in post_table_queries[0] + assert "status TEXT NOT NULL DEFAULT 'suggest'" in post_table_queries[0] + assert "is_anonymous INTEGER" in post_table_queries[0] + assert "FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE" in post_table_queries[0] # Проверяем создание таблицы контента - content_table_call = calls[1][0][0] - assert "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in content_table_call - assert "PRIMARY KEY (message_id, content_name)" in content_table_call + content_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS content_post_from_telegram" in q] + assert len(content_table_queries) > 0 + assert "PRIMARY KEY (message_id, content_name)" in content_table_queries[0] # Проверяем создание таблицы связей - link_table_call = calls[2][0][0] - assert "CREATE TABLE IF NOT EXISTS message_link_to_content" in link_table_call - assert "PRIMARY KEY (post_id, message_id)" in link_table_call + link_table_queries = [q for q in all_queries if "CREATE TABLE IF NOT EXISTS message_link_to_content" in q] + assert len(link_table_queries) > 0 + assert "PRIMARY KEY (post_id, message_id)" in link_table_queries[0] @pytest.mark.asyncio async def test_add_post_with_date(self, post_repository, sample_post): @@ -103,7 +108,7 @@ class TestPostRepository: query = call_args[0][0] params = call_args[0][1] - assert "INSERT INTO post_from_telegram_suggest" in query + assert "INSERT OR IGNORE INTO post_from_telegram_suggest" in query assert "status" in query assert "is_anonymous" in query assert "VALUES (?, ?, ?, ?, ?, ?)" in query @@ -148,9 +153,11 @@ class TestPostRepository: await post_repository.add_post(sample_post) - post_repository.logger.info.assert_called_once_with( - f"Пост добавлен: message_id={sample_post.message_id}" - ) + # Проверяем, что логирование вызвано с новым форматом сообщения + post_repository.logger.info.assert_called_once() + log_call = post_repository.logger.info.call_args[0][0] + assert f"message_id={sample_post.message_id}" in log_call + assert "Пост добавлен" in log_call or "уже существует" in log_call @pytest.mark.asyncio async def test_update_helper_message(self, post_repository): @@ -174,29 +181,61 @@ class TestPostRepository: @pytest.mark.asyncio async def test_update_status_by_message_id(self, post_repository): """Тест обновления статуса поста по message_id.""" + # Создаем таблицы перед тестом post_repository._execute_query = AsyncMock() + post_repository._execute_query_with_result = AsyncMock(return_value=[]) + post_repository._get_connection = AsyncMock() + mock_conn = AsyncMock() + mock_cur = AsyncMock() + mock_cur.fetchone = AsyncMock(return_value=(1,)) # 1 строка обновлена + mock_conn.execute = AsyncMock(return_value=mock_cur) + post_repository._get_connection.return_value = mock_conn post_repository.logger = MagicMock() + + # Создаем таблицы + await post_repository.create_tables() + post_repository._execute_query.reset_mock() + post_repository._execute_query_with_result.reset_mock() + post_repository.logger.info.reset_mock() # Сбрасываем счетчик логирования message_id = 12345 status = "approved" await post_repository.update_status_by_message_id(message_id, status) - post_repository._execute_query.assert_called_once() - call_args = post_repository._execute_query.call_args - query = call_args[0][0] - params = call_args[0][1] + # Проверяем, что conn.execute был вызван с правильными параметрами + assert mock_conn.execute.call_count >= 1 + update_call = mock_conn.execute.call_args_list[0] + query = update_call[0][0] + params = update_call[0][1] assert "UPDATE post_from_telegram_suggest" in query assert "SET status = ? WHERE message_id = ?" in query assert params == (status, message_id) - post_repository.logger.info.assert_called_once() + # Проверяем, что после создания таблиц было вызвано логирование обновления статуса + post_repository.logger.info.assert_called() + log_calls = [str(call) for call in post_repository.logger.info.call_args_list] + assert any("Статус поста message_id=12345 обновлён на approved" in str(call) for call in post_repository.logger.info.call_args_list) @pytest.mark.asyncio async def test_update_status_for_media_group_by_helper_id(self, post_repository): """Тест обновления статуса медиагруппы по helper_message_id.""" + # Создаем таблицы перед тестом post_repository._execute_query = AsyncMock() + post_repository._execute_query_with_result = AsyncMock(return_value=[]) + post_repository._get_connection = AsyncMock() + mock_conn = AsyncMock() + mock_cur = AsyncMock() + mock_cur.fetchone = AsyncMock(return_value=(1,)) # 1 строка обновлена + mock_conn.execute = AsyncMock(return_value=mock_cur) + post_repository._get_connection.return_value = mock_conn post_repository.logger = MagicMock() + + # Создаем таблицы + await post_repository.create_tables() + post_repository._execute_query.reset_mock() + post_repository._execute_query_with_result.reset_mock() + post_repository.logger.info.reset_mock() # Сбрасываем счетчик логирования helper_message_id = 99999 status = "declined" @@ -205,16 +244,19 @@ class TestPostRepository: helper_message_id, status ) - post_repository._execute_query.assert_called_once() - call_args = post_repository._execute_query.call_args - query = call_args[0][0] - params = call_args[0][1] + # Проверяем, что conn.execute был вызван с правильными параметрами + assert mock_conn.execute.call_count >= 1 + update_call = mock_conn.execute.call_args_list[0] + query = update_call[0][0] + params = update_call[0][1] assert "UPDATE post_from_telegram_suggest" in query assert "SET status = ?" in query assert "message_id = ? OR helper_text_message_id = ?" in query assert params == (status, helper_message_id, helper_message_id) - post_repository.logger.info.assert_called_once() + # Проверяем, что после создания таблиц было вызвано логирование обновления статуса + post_repository.logger.info.assert_called() + assert any("Статус медиагруппы helper_message_id=99999 обновлён на declined" in str(call) for call in post_repository.logger.info.call_args_list) @pytest.mark.asyncio async def test_add_post_content_success(self, post_repository): @@ -648,10 +690,12 @@ class TestPostRepository: @pytest.mark.asyncio async def test_create_tables_logs_success(self, post_repository): """Тест логирования успешного создания таблиц.""" - # Мокаем _execute_query и logger + # Мокаем _execute_query, _execute_query_with_result и logger post_repository._execute_query = AsyncMock() + post_repository._execute_query_with_result = AsyncMock(return_value=[]) # Для проверки столбца post_repository.logger = MagicMock() await post_repository.create_tables() - post_repository.logger.info.assert_called_once_with("Таблицы для постов созданы") + # Проверяем, что финальное сообщение о создании таблиц было вызвано + post_repository.logger.info.assert_any_call("Таблицы для постов созданы") diff --git a/tests/test_post_service.py b/tests/test_post_service.py index 10db950..bc77238 100644 --- a/tests/test_post_service.py +++ b/tests/test_post_service.py @@ -19,6 +19,7 @@ class TestPostService: db.add_post = AsyncMock() db.update_helper_message = AsyncMock() db.get_user_by_id = AsyncMock() + db.add_message_link = AsyncMock() return db @pytest.fixture @@ -60,8 +61,11 @@ class TestPostService: @pytest.mark.asyncio async def test_handle_text_post_saves_raw_text(self, post_service, mock_message, mock_db): """Test that handle_text_post saves raw text to database""" + mock_sent_message = Mock() + mock_sent_message.message_id = 200 + with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"): - with patch('helper_bot.handlers.private.services.send_text_message', return_value=200): + with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message): with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"): with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None): with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False): @@ -83,9 +87,11 @@ class TestPostService: async def test_handle_text_post_determines_anonymity(self, post_service, mock_message, mock_db): """Test that handle_text_post determines anonymity correctly""" mock_message.text = "Тестовый пост анон" + mock_sent_message = Mock() + mock_sent_message.message_id = 200 with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted text"): - with patch('helper_bot.handlers.private.services.send_text_message', return_value=200): + with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_sent_message): with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"): with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None): with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True): @@ -241,14 +247,17 @@ class TestPostService: album = [Mock()] album[0].caption = "Медиагруппа подпись" + mock_helper_message = Mock() + mock_helper_message.message_id = 302 + with patch('helper_bot.handlers.private.services.get_text_message', return_value="Formatted"): with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]): - with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=301): + with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[301]): with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"): with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None): - with patch('helper_bot.handlers.private.services.send_text_message', return_value=302): + with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message): with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=True): - with patch('asyncio.sleep', return_value=None): + with patch('asyncio.sleep', new_callable=AsyncMock): await post_service.handle_media_group_post(mock_message, album, "Test") @@ -257,7 +266,7 @@ class TestPostService: main_post = calls[0][0][0] assert main_post.text == "Медиагруппа подпись" # Raw caption - assert main_post.message_id == 300 + assert main_post.message_id == 301 # Последний message_id из списка assert main_post.is_anonymous is True @pytest.mark.asyncio @@ -269,14 +278,17 @@ class TestPostService: album = [Mock()] album[0].caption = None + mock_helper_message = Mock() + mock_helper_message.message_id = 303 + with patch('helper_bot.handlers.private.services.get_text_message', return_value=" "): with patch('helper_bot.handlers.private.services.prepare_media_group_from_middlewares', return_value=[]): - with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=302): + with patch('helper_bot.handlers.private.services.send_media_group_message_to_private_chat', return_value=[302]): with patch('helper_bot.handlers.private.services.get_first_name', return_value="Test"): with patch('helper_bot.handlers.private.services.get_reply_keyboard_for_post', return_value=None): - with patch('helper_bot.handlers.private.services.send_text_message', return_value=303): + with patch('helper_bot.handlers.private.services.send_text_message', return_value=mock_helper_message): with patch('helper_bot.handlers.private.services.determine_anonymity', return_value=False): - with patch('asyncio.sleep', return_value=None): + with patch('asyncio.sleep', new_callable=AsyncMock): await post_service.handle_media_group_post(mock_message, album, "Test") diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py index 10d0333..cc6d794 100644 --- a/tests/test_refactored_admin_handlers.py +++ b/tests/test_refactored_admin_handlers.py @@ -172,7 +172,7 @@ class TestAdminService: # Act & Assert with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"): - await self.admin_service.ban_user(user_id, username, reason, ban_days) + await self.admin_service.ban_user(user_id, username, reason, ban_days, ban_author_id=999) @pytest.mark.asyncio async def test_ban_user_permanent(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index c53812c..12651fc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -89,7 +89,6 @@ class TestHelperFunctions: """Тест функции get_text_message с is_anonymous=True""" text = "Тестовый пост" result = get_text_message(text, "Test", "testuser", is_anonymous=True) - assert "Пост из ТГ:" in result assert "Тестовый пост" in result assert "Пост опубликован анонимно" in result assert "Автор поста" not in result @@ -98,7 +97,6 @@ class TestHelperFunctions: """Тест функции get_text_message с is_anonymous=False""" text = "Тестовый пост" result = get_text_message(text, "Test", "testuser", is_anonymous=False) - assert "Пост из ТГ:" in result assert "Тестовый пост" in result assert "Автор поста" in result assert "Test" in result @@ -110,14 +108,12 @@ class TestHelperFunctions: # Тест с "анон" в тексте text = "Тестовый пост анон" result = get_text_message(text, "Test", "testuser", is_anonymous=None) - assert "Пост из ТГ:" in result assert "Тестовый пост анон" in result assert "Пост опубликован анонимно" in result # Тест с "неанон" в тексте text = "Тестовый пост неанон" result = get_text_message(text, "Test", "testuser", is_anonymous=None) - assert "Пост из ТГ:" in result assert "Тестовый пост неанон" in result assert "Автор поста" in result @@ -579,13 +575,14 @@ class TestSendMessageFunctions: mock_sent_message.message_id = 456 mock_message.bot.send_message.return_value = mock_sent_message - result = await send_text_message(123, mock_message, "Тестовое сообщение") - - assert result == 456 - mock_message.bot.send_message.assert_called_once_with( - chat_id=123, - text="Тестовое сообщение" - ) + # Мокаем rate_limiter (он импортируется внутри функции) + with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit: + mock_rate_limit.return_value = mock_sent_message + + result = await send_text_message(123, mock_message, "Тестовое сообщение") + + assert result == mock_sent_message + assert result.message_id == 456 @pytest.mark.asyncio async def test_send_text_message_with_markup(self): @@ -599,14 +596,14 @@ class TestSendMessageFunctions: mock_sent_message.message_id = 456 mock_message.bot.send_message.return_value = mock_sent_message - result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup) - - assert result == 456 - mock_message.bot.send_message.assert_called_once_with( - chat_id=123, - text="Тестовое сообщение", - reply_markup=mock_markup - ) + # Мокаем rate_limiter (он импортируется внутри функции) + with patch('helper_bot.utils.rate_limiter.send_with_rate_limit', new_callable=AsyncMock) as mock_rate_limit: + mock_rate_limit.return_value = mock_sent_message + + result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup) + + assert result == mock_sent_message + assert result.message_id == 456 @pytest.mark.asyncio async def test_send_photo_message(self): -- 2.49.1 From c53c0367519bd6b96ce347d63b66ff25701c08f7 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 25 Jan 2026 16:19:56 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=86=D0=B8=D1=8E=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/release-notes-template.md | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .cursor/rules/release-notes-template.md diff --git a/.cursor/rules/release-notes-template.md b/.cursor/rules/release-notes-template.md new file mode 100644 index 0000000..ebde57e --- /dev/null +++ b/.cursor/rules/release-notes-template.md @@ -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) +- **НЕ перечисляй** все файлы, которые были изменены +- **НЕ указывай** статистику строк кода +- **Фокусируйся** на функциональных изменениях, а не на технических деталях реализации +- Используй **реальные даты** из коммитов, а не предполагаемые -- 2.49.1