From 5d7b0515545dca19937c42a334a8b2097c84c0e2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 27 Jan 2026 22:10:04 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B0=20"declined?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализовано обновление статуса постов на "declined" для одиночных сообщений и медиагрупп. - Оптимизирована фоновая обработка постов, включая получение и обработку ML-скоров. - Обновлены обработчики для немедленного ответа пользователю при отправке постов. - Добавлены логирование ошибок для улучшения отладки. --- helper_bot/handlers/callback/services.py | 14 + .../handlers/private/private_handlers.py | 62 ++--- helper_bot/handlers/private/services.py | 254 ++++++++++++++++-- 3 files changed, 274 insertions(+), 56 deletions(-) diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index 4620e7f..51d0337 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -617,6 +617,20 @@ class BanService: ban_author=ban_author_id, ) + # Обновляем статус поста на declined + if call.message.text == CONTENT_TYPE_MEDIA_GROUP: + # Для медиагруппы обновляем статус по helper_message_id + updated_rows = await self.db.update_status_for_media_group_by_helper_id( + call.message.message_id, "declined" + ) + if updated_rows == 0: + logger.warning(f"Не удалось обновить статус медиагруппы helper_message_id={call.message.message_id} на 'declined'") + else: + # Для одиночного поста обновляем статус по message_id + updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined") + if updated_rows == 0: + logger.warning(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'") + await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M") diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index af01457..f34af93 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -147,43 +147,39 @@ class PrivateHandlers: @track_errors("private_handlers", "suggest_router") @track_time("suggest_router", "private_handlers") async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs): - """Handle post submission in suggest state""" + """Handle post submission in suggest state - сразу отвечает пользователю, обработка в фоне""" + # Сразу отвечаем пользователю + markup_for_user = await get_reply_keyboard(self.db, message.from_user.id) + success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE') + await message.answer(success_send_message, reply_markup=markup_for_user) + await state.set_state(FSM_STATES["START"]) + # Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп) album_getter = kwargs.get("album_getter") - 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: - # Ждем полную медиагруппу + # В фоне обрабатываем пост + async def process_post_background(): + try: + # Обновляем активность пользователя + await self.user_service.update_user_activity(message.from_user.id) + + # Логируем сообщение (только для одиночных сообщений, не медиагрупп) + if message.media_group_id is None: + await self.user_service.log_user_message(message) + + # Для медиагрупп ждем полную медиагруппу + if album_getter and message.media_group_id: full_album = await album_getter.get_album(timeout=10.0) - if 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"]) + if full_album: + await self.post_service.process_post(message, full_album) + else: + # Обычное сообщение или медиагруппа уже собрана + await self.post_service.process_post(message, album) + except Exception as e: + from logs.custom_logger import logger + logger.error(f"Ошибка при фоновой обработке поста: {e}") + + asyncio.create_task(process_post_background()) @error_handler @track_errors("private_handlers", "stickers") diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 3fc4582..5943d34 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -74,7 +74,8 @@ class UserService: """Ensure user exists in database, create if needed with metrics tracking""" user_id = message.from_user.id full_name = message.from_user.full_name - username = message.from_user.username or "private_username" + # Сохраняем только реальный username, если его нет - сохраняем None/пустую строку + username = message.from_user.username first_name = get_first_name(message) is_bot = message.from_user.is_bot language_code = message.from_user.language_code @@ -85,7 +86,7 @@ class UserService: user_id=user_id, first_name=first_name, full_name=full_name, - username=username, + username=username, # Может быть None - это нормально is_bot=is_bot, language_code=language_code, emoji="", @@ -104,6 +105,7 @@ class UserService: if is_need_update: await self.db.update_user_info(user_id, username, full_name) safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" + # Для отображения используем подстановочное значение, но в БД сохраняем только реальный username safe_username = html.escape(username) if username else "Без никнейма" await message.answer( @@ -177,6 +179,223 @@ class PostService: except Exception as e: logger.error(f"PostService: Ошибка сохранения скоров для {message_id}: {e}") + async def _get_scores_with_error_handling(self, text: str) -> tuple: + """ + Получает скоры для текста поста с обработкой ошибок. + + Returns: + Tuple (deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message) + error_message будет None если все ок, или строка с описанием ошибки + """ + if not self.scoring_manager: + # Скоры выключены в .env - это нормально + return None, None, None, None, None, None + + if not text or not text.strip(): + return None, None, None, None, None, None + + try: + scores = await self.scoring_manager.score_post(text) + + # Формируем JSON для сохранения в БД + import json + ml_scores_json = json.dumps(scores.to_json_dict()) if scores.has_any_score() else None + + # Получаем данные от RAG + rag_confidence = scores.rag.confidence if scores.rag else None + rag_score_pos_only = scores.rag.metadata.get("rag_score_pos_only") if scores.rag else None + + return scores.deepseek_score, scores.rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, None + except Exception as e: + logger.error(f"PostService: Ошибка получения скоров: {e}") + # Возвращаем частичные скоры если есть, или сообщение об ошибке + error_message = "Не удалось рассчитать скоры" + return None, None, None, None, None, error_message + + @track_time("_process_post_background", "post_service") + @track_errors("post_service", "_process_post_background") + async def _process_post_background( + self, + message: types.Message, + first_name: str, + content_type: str, + album: Union[list, None] = None + ) -> None: + """ + Обрабатывает пост в фоне: получает скоры, отправляет в группу модерации, сохраняет в БД. + + Args: + message: Сообщение от пользователя + first_name: Имя пользователя + content_type: Тип контента ('text', 'photo', 'video', 'audio', 'voice', 'video_note', 'media_group') + album: Список сообщений медиагруппы (только для media_group) + """ + try: + # Определяем исходный текст для скоринга и определения анонимности + original_raw_text = "" + if content_type == "text": + original_raw_text = message.text or "" + elif content_type == "media_group": + original_raw_text = album[0].caption or "" if album and album[0].caption else "" + else: + original_raw_text = message.caption or "" + + # Получаем скоры с обработкой ошибок + deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json, error_message = \ + await self._get_scores_with_error_handling(original_raw_text) + + # Формируем текст для поста (с сообщением об ошибке если есть) + text_for_post = original_raw_text + if error_message: + # Для текстовых постов добавляем в конец текста + if content_type == "text": + text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}" + # Для медиа добавляем в caption + elif content_type in ("photo", "video", "audio") and original_raw_text: + text_for_post = f"{original_raw_text}\n\n⚠️ {error_message}" + + # Формируем текст/caption с учетом скоров + post_text = "" + if text_for_post or content_type == "text": + post_text = get_text_message( + text_for_post.lower() if text_for_post else "", + first_name, + message.from_user.username, + deepseek_score=deepseek_score, + rag_score=rag_score, + rag_confidence=rag_confidence, + rag_score_pos_only=rag_score_pos_only, + ) + + # Определяем анонимность по исходному тексту (без сообщения об ошибке) + is_anonymous = determine_anonymity(original_raw_text) + + markup = get_reply_keyboard_for_post() + sent_message = None + + # Отправляем пост в группу модерации в зависимости от типа + if content_type == "text": + sent_message = await send_text_message( + self.settings.group_for_posts, message, post_text, markup + ) + elif content_type == "photo": + sent_message = await send_photo_message( + self.settings.group_for_posts, message, message.photo[-1].file_id, post_text, markup + ) + elif content_type == "video": + sent_message = await send_video_message( + self.settings.group_for_posts, message, message.video.file_id, post_text, markup + ) + elif content_type == "audio": + sent_message = await send_audio_message( + self.settings.group_for_posts, message, message.audio.file_id, post_text, markup + ) + elif content_type == "voice": + sent_message = await send_voice_message( + self.settings.group_for_posts, message, message.voice.file_id, markup + ) + elif content_type == "video_note": + sent_message = await send_video_note_message( + self.settings.group_for_posts, message, message.video_note.file_id, markup + ) + elif content_type == "media_group": + # Для медиагруппы используем специальную обработку + # Передаем ml_scores_json для сохранения в БД + await self._process_media_group_background( + message, album, first_name, post_text, is_anonymous, original_raw_text, ml_scores_json + ) + return + else: + logger.error(f"PostService: Неподдерживаемый тип контента: {content_type}") + return + + if not sent_message: + logger.error(f"PostService: Не удалось отправить пост типа {content_type}") + return + + # Сохраняем пост в БД (сохраняем исходный текст, без сообщения об ошибке) + post = TelegramPost( + message_id=sent_message.message_id, + text=original_raw_text, + author_id=message.from_user.id, + created_at=int(datetime.now().timestamp()), + is_anonymous=is_anonymous + ) + await self.db.add_post(post) + + # Сохраняем медиа и скоры в фоне + if content_type in ("photo", "video", "audio", "voice", "video_note"): + asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage)) + + if ml_scores_json: + asyncio.create_task(self._save_scores_background(sent_message.message_id, ml_scores_json)) + + except Exception as e: + logger.error(f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}") + + async def _process_media_group_background( + self, + message: types.Message, + album: list, + first_name: str, + post_caption: str, + is_anonymous: bool, + original_raw_text: str, + ml_scores_json: str = None + ) -> None: + """Обрабатывает медиагруппу в фоне""" + try: + 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=main_post_id, + text=original_raw_text, + author_id=message.from_user.id, + created_at=int(datetime.now().timestamp()), + is_anonymous=is_anonymous + ) + await self.db.add_post(main_post) + + # Сохраняем скоры в фоне (если они были получены) + if ml_scores_json: + asyncio.create_task(self._save_scores_background(main_post_id, ml_scores_json)) + + for msg_id in media_group_message_ids: + await self.db.add_message_link(main_post_id, msg_id) + + await asyncio.sleep(0.2) + + markup = get_reply_keyboard_for_post() + helper_message = await send_text_message( + self.settings.group_for_posts, + message, + "^", + markup + ) + helper_message_id = helper_message.message_id + + helper_post = TelegramPost( + message_id=helper_message_id, + text="^", + author_id=message.from_user.id, + helper_text_message_id=main_post_id, + created_at=int(datetime.now().timestamp()) + ) + await self.db.add_post(helper_post) + + await self.db.update_helper_message( + message_id=main_post_id, + helper_message_id=helper_message_id + ) + except Exception as e: + logger.error(f"PostService: Ошибка в _process_media_group_background: {e}") + @track_time("handle_text_post", "post_service") @track_errors("post_service", "handle_text_post") @db_query_time("handle_text_post", "posts", "insert") @@ -479,30 +698,19 @@ class PostService: @track_errors("post_service", "process_post") @track_media_processing("media_group") 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) - if message.media_group_id is not None: - await self.handle_media_group_post(message, album, first_name) - return + # Определяем тип контента + content_type = "media_group" if message.media_group_id is not None else message.content_type - content_handlers: Dict[str, Callable] = { - 'text': lambda: self.handle_text_post(message, first_name), - 'photo': lambda: self.handle_photo_post(message, first_name), - 'video': lambda: self.handle_video_post(message, first_name), - 'video_note': lambda: self.handle_video_note_post(message), - 'audio': lambda: self.handle_audio_post(message, first_name), - 'voice': lambda: self.handle_voice_post(message) - } - - handler = content_handlers.get(message.content_type) - if handler: - await handler() - else: - from .constants import ERROR_MESSAGES - await message.bot.send_message( - message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"] - ) + # Запускаем фоновую обработку + asyncio.create_task( + self._process_post_background(message, first_name, content_type, album) + ) class StickerService: