Dev 11 #13

Merged
KerradKerridi merged 5 commits from dev-11 into master 2026-01-25 13:22:31 +00:00
14 changed files with 992 additions and 143 deletions
Showing only changes of commit fecac6091e - Show all commits

View File

@@ -147,6 +147,22 @@ class AsyncBotDB:
"""Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом).""" """Алиас для get_post_content_from_telegram_by_last_id (используется callback-сервисом)."""
return await self.get_post_content_from_telegram_by_last_id(helper_message_id) 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]: async def get_post_text_from_telegram_by_last_id(self, last_post_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
return await self.factory.posts.get_post_text_by_helper_id(last_post_id) return await self.factory.posts.get_post_text_by_helper_id(last_post_id)

View File

@@ -19,11 +19,19 @@ class PostRepository(DatabaseConnection):
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest', status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER, is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE FOREIGN KEY (author_id) REFERENCES our_users (user_id) ON DELETE CASCADE
) )
''' '''
await self._execute_query(post_query) 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 = ''' content_query = '''
CREATE TABLE IF NOT EXISTS content_post_from_telegram ( CREATE TABLE IF NOT EXISTS content_post_from_telegram (
@@ -47,6 +55,26 @@ class PostRepository(DatabaseConnection):
''' '''
await self._execute_query(link_query) 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("Таблицы для постов созданы") self.logger.info("Таблицы для постов созданы")
async def add_post(self, post: TelegramPost) -> None: async def add_post(self, post: TelegramPost) -> None:
@@ -174,6 +202,20 @@ class PostRepository(DatabaseConnection):
self.logger.info(f"Получен контент поста: {len(post_content)} элементов") self.logger.info(f"Получен контент поста: {len(post_content)} элементов")
return 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]: async def get_post_text_by_helper_id(self, helper_message_id: int) -> Optional[str]:
"""Получает текст поста по helper_text_message_id.""" """Получает текст поста по helper_text_message_id."""
query = "SELECT text FROM post_from_telegram_suggest WHERE 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}") self.logger.info(f"Получены текст и is_anonymous для helper_message_id={helper_message_id}")
return text, is_anonymous return text, is_anonymous
return None, None 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

View File

@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS post_from_telegram_suggest (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'suggest', status TEXT NOT NULL DEFAULT 'suggest',
is_anonymous INTEGER, is_anonymous INTEGER,
published_message_id INTEGER,
FOREIGN KEY (author_id) REFERENCES our_users(user_id) ON DELETE CASCADE 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 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) -- Bot users information (user_id is now PRIMARY KEY)
CREATE TABLE IF NOT EXISTS our_users ( CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY, 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_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_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_our_users_date_changed ON our_users(date_changed);
CREATE INDEX IF NOT EXISTS idx_published_post_content_message_id ON published_post_content(published_message_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_published ON post_from_telegram_suggest(published_message_id);

View File

@@ -12,6 +12,14 @@ IMPORTANT_LOGS=-1001234567890
ARCHIVE=-1001234567890 ARCHIVE=-1001234567890
TEST_GROUP=-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 # Bot Settings
PREVIEW_LINK=false PREVIEW_LINK=false
LOGS=false LOGS=false

View File

@@ -13,7 +13,8 @@ def get_post_publish_service() -> PostPublishService:
db = bdf.get_db() db = bdf.get_db()
settings = bdf.settings 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: def get_ban_service() -> BanService:

View File

@@ -3,6 +3,7 @@ import html
from typing import Dict, Any from typing import Dict, Any
from aiogram import Bot from aiogram import Bot
from aiogram import types
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
@@ -33,11 +34,12 @@ from helper_bot.utils.metrics import (
class PostPublishService: 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 - в этом случае используем бота из контекста сообщения # bot может быть None - в этом случае используем бота из контекста сообщения
self.bot = bot self.bot = bot
self.db = db self.db = db
self.settings = settings self.settings = settings
self.s3_storage = s3_storage
self.group_for_posts = settings['Telegram']['group_for_posts'] self.group_for_posts = settings['Telegram']['group_for_posts']
self.main_public = settings['Telegram']['main_public'] self.main_public = settings['Telegram']['main_public']
self.important_logs = settings['Telegram']['important_logs'] self.important_logs = settings['Telegram']['important_logs']
@@ -98,9 +100,16 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, 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) 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_time("_publish_photo_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_photo_post") @track_errors("post_publish_service", "_publish_photo_post")
@@ -126,9 +135,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, 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) 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_time("_publish_video_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_post") @track_errors("post_publish_service", "_publish_video_post")
@@ -154,9 +173,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, 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) 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_time("_publish_video_note_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_note_post") @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'") logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") 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) 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_time("_publish_audio_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_audio_post") @track_errors("post_publish_service", "_publish_audio_post")
@@ -197,9 +236,19 @@ class PostPublishService:
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, 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) 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_time("_publish_voice_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_voice_post") @track_errors("post_publish_service", "_publish_voice_post")
@@ -212,9 +261,19 @@ class PostPublishService:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'") logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных") 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) 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_time("_publish_media_group", "post_publish_service")
@track_errors("post_publish_service", "_publish_media_group") @track_errors("post_publish_service", "_publish_media_group")
@@ -259,17 +318,36 @@ class PostPublishService:
# Отправляем медиагруппу в канал # Отправляем медиагруппу в канал
logger.info(f"Отправляю медиагруппу в канал {self.main_public}") 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), bot=self._get_bot(call.message),
chat_id=self.main_public, chat_id=self.main_public,
post_content=post_content, 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") await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved")
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}") logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
await self._delete_media_group_and_notify_author(call, 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: except Exception as e:
logger.error(f"Ошибка при публикации медиагруппы: {e}") logger.error(f"Ошибка при публикации медиагруппы: {e}")
@@ -412,6 +490,34 @@ class PostPublishService:
raise UserBlockedBotError("Пользователь заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота")
raise 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: class BanService:
def __init__(self, bot: Bot, db, settings: Dict[str, Any]): def __init__(self, bot: Bot, db, settings: Dict[str, Any]):

View File

@@ -44,11 +44,11 @@ sleep = asyncio.sleep
class PrivateHandlers: class PrivateHandlers:
"""Main handler class for private messages""" """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.db = db
self.settings = settings self.settings = settings
self.user_service = UserService(db, 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) self.sticker_service = StickerService(settings)
# Create router # Create router
@@ -224,9 +224,9 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies # 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""" """Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings) return PrivateHandlers(db, settings, s3_storage)
# Legacy router for backward compatibility # Legacy router for backward compatibility
@@ -252,7 +252,8 @@ def init_legacy_router():
) )
db = bdf.get_db() 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 # Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture # This maintains backward compatibility while using the new architecture

View File

@@ -143,9 +143,19 @@ class UserService:
class PostService: class PostService:
"""Service for post-related operations""" """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.db = db
self.settings = settings 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_time("handle_text_post", "post_service")
@track_errors("post_service", "handle_text_post") @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) post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
markup = get_reply_keyboard_for_post() 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 "" raw_text = message.text or ""
is_anonymous = determine_anonymity(raw_text) is_anonymous = determine_anonymity(raw_text)
post = TelegramPost( post = TelegramPost(
message_id=sent_message_id, message_id=sent_message.message_id,
text=raw_text, text=raw_text,
author_id=message.from_user.id, author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()), created_at=int(datetime.now().timestamp()),
@@ -196,9 +206,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_post", "post_service") @track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post") @track_errors("post_service", "handle_video_post")
@@ -226,9 +235,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_video_note_post", "post_service") @track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post") @track_errors("post_service", "handle_video_note_post")
@@ -252,9 +260,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_audio_post", "post_service") @track_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post") @track_errors("post_service", "handle_audio_post")
@@ -282,9 +289,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_voice_post", "post_service") @track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post") @track_errors("post_service", "handle_voice_post")
@@ -308,9 +314,8 @@ class PostService:
is_anonymous=is_anonymous is_anonymous=is_anonymous
) )
await self.db.add_post(post) await self.db.add_post(post)
success = await add_in_db_media(sent_message, self.db) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}")
@track_time("handle_media_group_post", "post_service") @track_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post") @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 = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_id = await send_media_group_message_to_private_chat( 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) await asyncio.sleep(0.2)
# Создаем helper сообщение с кнопками # Создаем helper сообщение с кнопками
markup = get_reply_keyboard_for_post() 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 пост и связываем его с основным
helper_post = TelegramPost( helper_post = TelegramPost(
message_id=help_message_id, # ID helper сообщения message_id=help_message.message_id, # ID helper сообщения
text="^", # Специальный маркер для медиагруппы text="^", # Специальный маркер для медиагруппы
author_id=message.from_user.id, author_id=message.from_user.id,
helper_text_message_id=main_post.message_id, # Ссылка на основной пост helper_text_message_id=main_post.message_id, # Ссылка на основной пост
@@ -364,7 +369,7 @@ class PostService:
# Обновляем основной пост, чтобы он ссылался на helper # Обновляем основной пост, чтобы он ссылался на helper
await self.db.update_helper_message( await self.db.update_helper_message(
message_id=main_post.message_id, 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") @track_time("process_post", "post_service")

View File

@@ -1,8 +1,10 @@
import os import os
import sys import sys
from typing import Optional
from dotenv import load_dotenv from dotenv import load_dotenv
from database.async_db import AsyncBotDB from database.async_db import AsyncBotDB
from helper_bot.utils.s3_storage import S3StorageService
class BaseDependencyFactory: class BaseDependencyFactory:
@@ -21,6 +23,7 @@ class BaseDependencyFactory:
self.database = AsyncBotDB(database_path) self.database = AsyncBotDB(database_path)
self._load_settings_from_env() self._load_settings_from_env()
self._init_s3_storage()
def _load_settings_from_env(self): def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения.""" """Загружает настройки из переменных окружения."""
@@ -48,6 +51,29 @@ class BaseDependencyFactory:
'port': self._parse_int(os.getenv('METRICS_PORT', '8080')) '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: def _parse_bool(self, value: str) -> bool:
"""Парсит строковое значение в boolean.""" """Парсит строковое значение в boolean."""
return value.lower() in ('true', '1', 'yes', 'on') return value.lower() in ('true', '1', 'yes', 'on')
@@ -66,6 +92,10 @@ class BaseDependencyFactory:
"""Возвращает подключение к базе данных.""" """Возвращает подключение к базе данных."""
return self.database return self.database
def get_s3_storage(self) -> Optional[S3StorageService]:
"""Возвращает S3StorageService если S3 включен, иначе None."""
return self.s3_storage
_global_instance = None _global_instance = None

View File

@@ -2,6 +2,8 @@ import html
import os import os
import random import random
import time import time
import tempfile
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union 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_time("download_file", "helper_func")
@track_errors("helper_func", "download_file") @track_errors("helper_func", "download_file")
@track_file_operations("unknown") @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: Args:
message: сообщение message: сообщение
file_id: File ID файла file_id: File ID файла
content_type: тип контента (photo, video, audio, voice, video_note) content_type: тип контента (photo, video, audio, voice, video_note)
s3_storage: опциональный S3StorageService для сохранения в S3
Returns: Returns:
Путь к сохраненному файлу, если файл был скачан успешно, иначе None S3 ключ (если s3_storage указан) или локальный путь к файлу, иначе None
""" """
start_time = time.time() start_time = time.time()
@@ -178,6 +182,58 @@ async def download_file(message: types.Message, file_id: str, content_type: str
logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют") logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
return None return None
# Получаем информацию о файле
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'
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 = { type_folders = {
'photo': 'photos', 'photo': 'photos',
@@ -197,15 +253,7 @@ async def download_file(message: types.Message, file_id: str, content_type: str
logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}") 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}" safe_filename = f"{file_id}{file_extension}"
file_path = os.path.join(full_folder_path, safe_filename) file_path = os.path.join(full_folder_path, safe_filename)
@@ -283,11 +331,21 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
return media_group 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_time("add_in_db_media_mediagroup", "helper_func")
@track_errors("helper_func", "add_in_db_media_mediagroup") @track_errors("helper_func", "add_in_db_media_mediagroup")
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("add_in_db_media_mediagroup", "posts", "insert") @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,18 +409,26 @@ 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)}") logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}")
# Скачиваем файл # Получаем s3_storage если не передан
file_path = await download_file(message, file_id=file_id, content_type=content_type) 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: if not file_path:
logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}") logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}")
failed_count += 1 failed_count += 1
continue 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: if not success:
logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}") logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}")
# Удаляем скачанный файл при ошибке БД # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3)
if file_path.startswith('files/'):
try: try:
os.remove(file_path) os.remove(file_path)
logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД") logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД")
@@ -402,7 +468,7 @@ async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db:
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("add_in_db_media", "posts", "insert") @db_query_time("add_in_db_media", "posts", "insert")
@track_file_operations("media") @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}") logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}")
# Скачиваем файл # Получаем s3_storage если не передан
file_path = await download_file(sent_message, file_id=file_id, content_type=content_type) 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: if not file_path:
logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}") logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}")
return False return False
@@ -461,7 +532,8 @@ 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) success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type)
if not success: if not success:
logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}") logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}")
# Удаляем скачанный файл при ошибке БД # Удаляем скачанный файл при ошибке БД (только если это локальный файл, не S3)
if file_path.startswith('files/'):
try: try:
os.remove(file_path) os.remove(file_path)
logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД") logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД")
@@ -484,7 +556,7 @@ async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool:
@track_media_processing("media_group") @track_media_processing("media_group")
@db_query_time("send_media_group_message_to_private_chat", "posts", "insert") @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, 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( sent_message = await message.bot.send_media_group(
chat_id=chat_id, chat_id=chat_id,
media=media_group, media=media_group,
@@ -496,46 +568,69 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types.
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await bot_db.add_post(post) await bot_db.add_post(post)
success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id) # Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
if not success: asyncio.create_task(_save_media_group_background(sent_message, bot_db, main_post_id, s3_storage))
logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}")
message_id = sent_message[-1].message_id message_id = sent_message[-1].message_id
return message_id return message_id
@track_time("send_media_group_to_channel", "helper_func") @track_time("send_media_group_to_channel", "helper_func")
@track_errors("helper_func", "send_media_group_to_channel") @track_errors("helper_func", "send_media_group_to_channel")
@track_media_processing("media_group") @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: Args:
bot: Экземпляр бота aiogram. bot: Экземпляр бота aiogram.
chat_id: ID чата для отправки. chat_id: ID чата для отправки.
post_content: Список кортежей с путями к файлам. post_content: Список кортежей с путями к файлам (локальные пути или S3 ключи).
post_text: Текст подписи. post_text: Текст подписи.
s3_storage: опциональный S3StorageService для работы с S3.
""" """
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}") logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
media = [] # Получаем s3_storage если не передан
for i, file_path in enumerate(post_content): if s3_storage is None:
try: bdf = get_global_instance()
file = FSInputFile(path=file_path[0]) s3_storage = bdf.get_s3_storage()
type = file_path[1]
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})")
if type == 'video': media = []
temp_files = [] # Для хранения путей к временным файлам
try:
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)) media.append(types.InputMediaVideo(media=file))
elif type == 'photo': elif content_type == 'photo':
media.append(types.InputMediaPhoto(media=file)) media.append(types.InputMediaPhoto(media=file))
else: else:
logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}") logger.warning(f"Неизвестный тип файла: {content_type} для {file_path}")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Файл не найден: {file_path[0]}") logger.error(f"Файл не найден: {file_path_tuple[0]}")
return continue
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}") logger.error(f"Ошибка при обработке файла {file_path_tuple[0]}: {e}")
return continue
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки") logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
@@ -547,11 +642,19 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}") logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
try: try:
await bot.send_media_group(chat_id=chat_id, media=media) sent_messages = await bot.send_media_group(chat_id=chat_id, media=media)
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}") logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}, количество сообщений: {len(sent_messages)}")
return sent_messages
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}") logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
raise raise
finally:
# Удаляем временные файлы
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
@track_time("send_text_message", "helper_func") @track_time("send_text_message", "helper_func")
@track_errors("helper_func", "send_text_message") @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) 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_time("send_photo_message", "helper_func")
@track_errors("helper_func", "send_photo_message") @track_errors("helper_func", "send_photo_message")

View File

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

View File

@@ -28,3 +28,6 @@ pluggy==1.5.0
attrs~=23.2.0 attrs~=23.2.0
typing_extensions~=4.12.2 typing_extensions~=4.12.2
emoji~=2.8.0 emoji~=2.8.0
# S3 Storage (для хранения медиафайлов опубликованных постов)
aioboto3>=12.0.0

View File

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

144
scripts/test_s3_connection.py Executable file
View File

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