feat: улучшена обработка постов и медиагрупп с добавлением статуса "declined"

- Реализовано обновление статуса постов на "declined" для одиночных сообщений и медиагрупп.
- Оптимизирована фоновая обработка постов, включая получение и обработку ML-скоров.
- Обновлены обработчики для немедленного ответа пользователю при отправке постов.
- Добавлены логирование ошибок для улучшения отладки.
This commit is contained in:
2026-01-27 22:10:04 +03:00
parent be8af704ba
commit 5d7b051554
3 changed files with 274 additions and 56 deletions

View File

@@ -617,6 +617,20 @@ class BanService:
ban_author=ban_author_id, 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) 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") date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")

View File

@@ -147,43 +147,39 @@ class PrivateHandlers:
@track_errors("private_handlers", "suggest_router") @track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers") @track_time("suggest_router", "private_handlers")
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs): 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") album_getter = kwargs.get("album_getter")
if album_getter and message.media_group_id: # В фоне обрабатываем пост
# Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне async def process_post_background():
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: 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.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: if message.media_group_id is None:
await self.user_service.log_user_message(message) await self.user_service.log_user_message(message)
# Для медиагрупп ждем полную медиагруппу
if album_getter and message.media_group_id:
full_album = await album_getter.get_album(timeout=10.0)
if full_album:
await self.post_service.process_post(message, full_album)
else:
# Обычное сообщение или медиагруппа уже собрана
await self.post_service.process_post(message, album) await self.post_service.process_post(message, album)
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id) except Exception as e:
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE') from logs.custom_logger import logger
await message.answer(success_send_message, reply_markup=markup_for_user) logger.error(f"Ошибка при фоновой обработке поста: {e}")
await state.set_state(FSM_STATES["START"])
asyncio.create_task(process_post_background())
@error_handler @error_handler
@track_errors("private_handlers", "stickers") @track_errors("private_handlers", "stickers")

View File

@@ -74,7 +74,8 @@ class UserService:
"""Ensure user exists in database, create if needed with metrics tracking""" """Ensure user exists in database, create if needed with metrics tracking"""
user_id = message.from_user.id user_id = message.from_user.id
full_name = message.from_user.full_name 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) first_name = get_first_name(message)
is_bot = message.from_user.is_bot is_bot = message.from_user.is_bot
language_code = message.from_user.language_code language_code = message.from_user.language_code
@@ -85,7 +86,7 @@ class UserService:
user_id=user_id, user_id=user_id,
first_name=first_name, first_name=first_name,
full_name=full_name, full_name=full_name,
username=username, username=username, # Может быть None - это нормально
is_bot=is_bot, is_bot=is_bot,
language_code=language_code, language_code=language_code,
emoji="", emoji="",
@@ -104,6 +105,7 @@ class UserService:
if is_need_update: if is_need_update:
await self.db.update_user_info(user_id, username, full_name) await self.db.update_user_info(user_id, username, full_name)
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь" safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
# Для отображения используем подстановочное значение, но в БД сохраняем только реальный username
safe_username = html.escape(username) if username else "Без никнейма" safe_username = html.escape(username) if username else "Без никнейма"
await message.answer( await message.answer(
@@ -177,6 +179,223 @@ class PostService:
except Exception as e: except Exception as e:
logger.error(f"PostService: Ошибка сохранения скоров для {message_id}: {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_time("handle_text_post", "post_service")
@track_errors("post_service", "handle_text_post") @track_errors("post_service", "handle_text_post")
@db_query_time("handle_text_post", "posts", "insert") @db_query_time("handle_text_post", "posts", "insert")
@@ -479,29 +698,18 @@ class PostService:
@track_errors("post_service", "process_post") @track_errors("post_service", "process_post")
@track_media_processing("media_group") @track_media_processing("media_group")
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None: 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) first_name = get_first_name(message)
if message.media_group_id is not None: # Определяем тип контента
await self.handle_media_group_post(message, album, first_name) content_type = "media_group" if message.media_group_id is not None else message.content_type
return
content_handlers: Dict[str, Callable] = { # Запускаем фоновую обработку
'text': lambda: self.handle_text_post(message, first_name), asyncio.create_task(
'photo': lambda: self.handle_photo_post(message, first_name), self._process_post_background(message, first_name, content_type, album)
'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"]
) )