Добавлен функционал для работы с S3 хранилищем и обновление контента опубликованных постов

- В `env.example` добавлены настройки для S3 хранилища.
- Обновлен файл зависимостей `requirements.txt`, добавлена библиотека `aioboto3` для работы с S3.
- В `PostRepository` и `AsyncBotDB` реализованы методы для обновления и получения контента опубликованных постов.
- Обновлены обработчики публикации постов для сохранения идентификаторов опубликованных сообщений и их контента.
- Реализована логика сохранения медиафайлов в S3 или на локальный диск в зависимости от конфигурации.
- Обновлены тесты для проверки нового функционала.
This commit is contained in:
2026-01-23 23:19:16 +03:00
parent 42f168f329
commit fecac6091e
14 changed files with 992 additions and 143 deletions

View File

@@ -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:

View File

@@ -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]):

View File

@@ -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

View File

@@ -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")