Dev 8 #10

Merged
KerradKerridi merged 15 commits from dev-8 into master 2025-09-03 22:00:36 +00:00
12 changed files with 340 additions and 232 deletions
Showing only changes of commit 1ab427a7ba - Show all commits

1
.gitignore vendored
View File

@@ -92,3 +92,4 @@ venv.bak/
# Other files
voice_users/
files/

View File

@@ -145,32 +145,42 @@ async def process_ban_target(
bot_db: MagicData("bot_db")
):
"""Обработка введенного username/ID для блокировки"""
logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
try:
user_data = await state.get_data()
ban_type = user_data.get('ban_type')
admin_service = AdminService(bot_db)
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
# Определяем пользователя
if ban_type == "username":
logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}")
user = await admin_service.get_user_by_username(message.text)
if not user:
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден")
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
await return_to_admin_menu(message, state)
return
else: # ban_type == "id"
try:
logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}")
user_id = await admin_service.validate_user_input(message.text)
user = await admin_service.get_user_by_id(user_id)
if not user:
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных")
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.")
await return_to_admin_menu(message, state)
return
except InvalidInputError as e:
logger.error(f"process_ban_target: Ошибка валидации ID: {e}")
await message.answer(str(e))
await return_to_admin_menu(message, state)
return
logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}")
# Сохраняем данные пользователя
await state.update_data(
target_user_id=user.user_id,
@@ -181,13 +191,17 @@ async def process_ban_target(
# Показываем информацию о пользователе и запрашиваем причину
user_info = format_user_info(user.user_id, user.username, user.full_name)
markup = create_keyboard_for_ban_reason()
logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}")
await message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup
)
await state.set_state('AWAIT_BAN_DETAILS')
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
except Exception as e:
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_target")
@@ -201,16 +215,33 @@ async def process_ban_reason(
**kwargs
):
"""Обработка причины блокировки"""
logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
try:
# Проверяем текущее состояние
current_state = await state.get_state()
logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
# Проверяем данные состояния
state_data = await state.get_data()
logger.info(f"process_ban_reason: Данные состояния: {state_data}")
logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}")
await state.update_data(ban_reason=message.text)
markup = create_keyboard_for_ban_days()
safe_reason = escape_html(message.text)
logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}")
await message.answer(
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
reply_markup=markup
)
await state.set_state('AWAIT_BAN_DURATION')
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
except Exception as e:
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_reason")

View File

@@ -16,14 +16,18 @@ def escape_html(text: str) -> str:
async def return_to_admin_menu(message: types.Message, state: FSMContext,
additional_message: Optional[str] = None) -> None:
"""Универсальная функция для возврата в админ-меню"""
logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}")
await state.set_data({})
await state.set_state("ADMIN")
markup = get_reply_keyboard_admin()
if additional_message:
logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}")
await message.answer(additional_message)
await message.answer('Вернулись в меню', reply_markup=markup)
logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню")
async def handle_admin_error(message: types.Message, error: Exception,

View File

@@ -1,5 +1,5 @@
import html
from datetime import datetime, timedelta
import html
from typing import Dict, Any
from aiogram import Bot
@@ -42,9 +42,14 @@ class PostPublishService:
async def publish_post(self, call: CallbackQuery) -> None:
"""Основной метод публикации поста"""
# Проверяем, является ли сообщение частью медиагруппы
if call.message.media_group_id:
await self._publish_media_group(call)
return
content_type = call.message.content_type
if content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP:
if content_type == CONTENT_TYPE_TEXT:
await self._publish_text_post(call)
elif content_type == CONTENT_TYPE_PHOTO:
await self._publish_photo_post(call)
@@ -56,8 +61,6 @@ class PostPublishService:
await self._publish_audio_post(call)
elif content_type == CONTENT_TYPE_VOICE:
await self._publish_voice_post(call)
elif call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._publish_media_group(call)
else:
raise PublishError(f"Неподдерживаемый тип контента: {content_type}")
@@ -115,51 +118,111 @@ class PostPublishService:
async def _publish_media_group(self, call: CallbackQuery) -> None:
"""Публикация медиагруппы"""
post_content = await self.db.get_post_content_from_telegram_by_last_id(call.message.message_id)
pre_text = await self.db.get_post_text_from_telegram_by_last_id(call.message.message_id)
post_text = html.escape(str(pre_text))
author_id = await self._get_author_id_for_media_group(call.message.message_id)
await send_media_group_to_channel(bot=self._get_bot(call.message), chat_id=self.main_public, post_content=post_content, post_text=post_text)
await self._delete_media_group_and_notify_author(call, author_id)
logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
try:
# call.message.message_id - это ID helper сообщения
helper_message_id = call.message.message_id
# Получаем контент медиагруппы по helper_message_id
logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}")
post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
if not post_content:
logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}")
raise PublishError("Контент медиагруппы не найден в базе данных")
# Получаем текст поста по helper_message_id
logger.debug(f"Получаю текст поста для helper_message_id: {helper_message_id}")
pre_text = await self.db.get_post_text_by_helper_id(helper_message_id)
post_text = html.escape(str(pre_text)) if pre_text else ""
logger.debug(f"Текст поста получен: {'пустой' if not post_text else f'длина: {len(post_text)} символов'}")
# Получаем ID автора по helper_message_id
logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}")
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
if not author_id:
logger.error(f"Автор не найден для медиагруппы {helper_message_id}")
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
logger.debug(f"ID автора получен: {author_id}")
# Отправляем медиагруппу в канал
logger.info(f"Отправляю медиагруппу в канал {self.main_public}")
await send_media_group_to_channel(
bot=self._get_bot(call.message),
chat_id=self.main_public,
post_content=post_content,
post_text=post_text
)
logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}")
await self._delete_media_group_and_notify_author(call, author_id)
logger.info(f'Медиагруппа опубликована в канале {self.main_public}.')
except Exception as e:
logger.error(f"Ошибка при публикации медиагруппы: {e}")
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
async def decline_post(self, call: CallbackQuery) -> None:
"""Отклонение поста"""
logger.info(f"Начинаю отклонение поста. Message ID: {call.message.message_id}, Content type: {call.message.content_type}")
# Проверяем, является ли сообщение частью медиагруппы (осознанный костыль, т.к. сообщение к которому прикреплен коллбек без медиагруппы)
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
logger.debug("Сообщение является частью медиагруппы, вызываю _decline_media_group")
await self._decline_media_group(call)
return
content_type = call.message.content_type
if (content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP) or \
content_type in [CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
logger.debug(f"Отклоняю одиночный пост типа: {content_type}")
await self._decline_single_post(call)
elif call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._decline_media_group(call)
else:
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
async def _decline_single_post(self, call: CallbackQuery) -> None:
"""Отклонение одиночного поста"""
logger.debug(f"Отклоняю одиночный пост. Message ID: {call.message.message_id}")
author_id = await self._get_author_id(call.message.message_id)
logger.debug(f"ID автора получен: {author_id}")
logger.debug(f"Удаляю сообщение из группы {self.group_for_posts}")
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
try:
logger.debug(f"Отправляю уведомление об отклонении автору {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
raise
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
async def _decline_media_group(self, call: CallbackQuery) -> None:
"""Отклонение медиагруппы"""
logger.debug(f"Отклоняю медиагруппу. Helper message ID: {call.message.message_id}")
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
message_ids = [row[0] for row in post_ids]
message_ids = post_ids.copy()
message_ids.append(call.message.message_id)
logger.debug(f"Получены ID сообщений для удаления: {message_ids}")
author_id = await self._get_author_id_for_media_group(call.message.message_id)
logger.debug(f"ID автора медиагруппы получен: {author_id}")
logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}")
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
try:
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}")
raise
async def _get_author_id(self, message_id: int) -> int:
@@ -171,7 +234,27 @@ class PostPublishService:
async def _get_author_id_for_media_group(self, message_id: int) -> int:
"""Получение ID автора для медиагруппы"""
# Сначала пытаемся найти автора по helper_message_id
author_id = await self.db.get_author_id_by_helper_message_id(message_id)
if author_id:
return author_id
# Если не найден, ищем по основному message_id медиагруппы
# Для этого нужно найти связанные сообщения медиагруппы
try:
# Получаем все ID сообщений медиагруппы
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(message_id)
if post_ids:
# Берем первый ID (основное сообщение медиагруппы)
main_message_id = post_ids[0]
author_id = await self.db.get_author_id_by_message_id(main_message_id)
if author_id:
return author_id
except Exception as e:
logger.warning(f"Не удалось найти автора через связанные сообщения: {e}")
# Если все способы не сработали, ищем напрямую
author_id = await self.db.get_author_id_by_message_id(message_id)
if not author_id:
raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}")
return author_id
@@ -190,9 +273,10 @@ class PostPublishService:
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
"""Удаление медиагруппы и уведомление автора"""
post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id)
message_ids = [row[0] for row in post_ids]
message_ids.append(call.message.message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
#message_ids = post_ids.copy()
post_ids.append(call.message.message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_ids)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:

View File

@@ -272,6 +272,16 @@ class PostService:
if album and album[0].caption:
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
# Создаем основной пост для медиагруппы
main_post = TelegramPost(
message_id=message.message_id, # ID основного сообщения медиагруппы
text=post_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(main_post)
# Отправляем медиагруппу в группу для модерации
media_group = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_id = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db
@@ -279,11 +289,24 @@ class PostService:
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, "^", markup)
help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА")
# Создаем helper пост и связываем его с основным
helper_post = TelegramPost(
message_id=help_message_id, # ID helper сообщения
text="^", # Специальный маркер для медиагруппы
author_id=message.from_user.id,
helper_text_message_id=main_post.message_id, # Ссылка на основной пост
created_at=int(datetime.now().timestamp())
)
await self.db.add_post(helper_post)
# Обновляем основной пост, чтобы он ссылался на helper
await self.db.update_helper_message(
message_id=media_group_message_id, helper_message_id=help_message_id
message_id=main_post.message_id,
helper_message_id=help_message_id
)
@track_time("process_post", "post_service")
@@ -291,7 +314,7 @@ class PostService:
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
"""Process post based on content type"""
first_name = get_first_name(message)
# TODO: Бесит меня этот функционал
if message.media_group_id is not None:
safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма"
await send_text_message(

View File

@@ -30,7 +30,10 @@ BTN_LISTEN = "🎧Послушать"
# Button to command mapping for metrics
BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"🎤Высказаться": "voice_speak",
"🎧Послушать": "voice_listen"
"🎧Послушать": "voice_listen",
"Отменить": "voice_cancel",
"🔄Сбросить прослушивания": "voice_refresh_listen",
"😊Узнать эмодзи": "voice_emoji"
}
# Callback data

View File

@@ -1,5 +1,6 @@
import random
import asyncio
import traceback
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
@@ -255,22 +256,40 @@ class AudioFileService:
async def download_and_save_audio(self, bot, message, file_name: str) -> None:
"""Скачать и сохранить аудио файл"""
try:
logger.info(f"Начинаем скачивание и сохранение аудио: {file_name}")
# Проверяем наличие голосового сообщения
if not message or not message.voice:
logger.error("Сообщение или голосовое сообщение не найдено")
raise FileOperationError("Сообщение или голосовое сообщение не найдено")
file_id = message.voice.file_id
logger.info(f"Получен file_id: {file_id}")
file_info = await bot.get_file(file_id=file_id)
logger.info(f"Получена информация о файле: {file_info.file_path}")
downloaded_file = await bot.download_file(file_path=file_info.file_path)
logger.info(f"Файл скачан, размер: {len(downloaded_file.read()) if downloaded_file else 'None'} bytes")
# Сбрасываем позицию в файле
downloaded_file.seek(0)
# Создаем директорию если она не существует
import os
os.makedirs(VOICE_USERS_DIR, exist_ok=True)
logger.info(f"Директория {VOICE_USERS_DIR} создана/проверена")
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
logger.info(f"Сохраняем файл по пути: {file_path}")
# Сохраняем файл
with open(f'{VOICE_USERS_DIR}/{file_name}.ogg', 'wb') as new_file:
with open(file_path, 'wb') as new_file:
new_file.write(downloaded_file.read())
logger.info(f"Файл успешно сохранен: {file_path}")
except Exception as e:
logger.error(f"Ошибка при скачивании и сохранении аудио: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise FileOperationError(f"Не удалось скачать и сохранить аудио: {e}")

View File

@@ -18,12 +18,14 @@ from helper_bot.handlers.voice.constants import *
from helper_bot.handlers.voice.services import VoiceBotService
from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe
from helper_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice
from helper_bot.keyboards import get_reply_keyboard
from helper_bot.handlers.private.constants import FSM_STATES
from helper_bot.handlers.private.constants import BUTTON_TEXTS
class VoiceHandlers:
def __init__(self, db, settings):
self.db = db
self.db = db.get_db() if hasattr(db, 'get_db') else db
self.settings = settings
self.router = Router()
self._setup_handlers()
@@ -93,6 +95,25 @@ class VoiceHandlers:
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_LISTEN
)
# Новые обработчики кнопок
self.router.message.register(
self.cancel_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == "Отменить"
)
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
F.text == "🔄Сбросить прослушивания"
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
F.text == "😊Узнать эмодзи"
)
async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")):
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
@@ -180,6 +201,19 @@ class VoiceHandlers:
except Exception as e:
logger.error(f"Ошибка при отметке получения приветствия: {e}")
async def cancel_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await update_user_info(VOICE_BOT_NAME, message)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML')
await state.set_state(FSM_STATES["START"])
async def refresh_listen_function(
self,
@@ -334,7 +368,7 @@ class VoiceHandlers:
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id)
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id) - 1
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id)
await message.answer(
text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup

View File

@@ -25,13 +25,13 @@ def get_reply_keyboard_for_post():
@track_time("get_reply_keyboard", "keyboard_service")
@track_errors("keyboard_service", "get_reply_keyboard")
async def get_reply_keyboard(BotDB, user_id):
async def get_reply_keyboard(db, user_id):
builder = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
builder.row(types.KeyboardButton(text="📩Связаться с админами"))
builder.row(types.KeyboardButton(text=" 🎤Голосовой бот"))
builder.row(types.KeyboardButton(text="👋🏼Сказать пока!"))
if not await BotDB.get_stickers_info(user_id):
if not await db.get_stickers_info(user_id):
builder.row(types.KeyboardButton(text="🤪Хочу стикеры"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@@ -175,8 +175,18 @@ def create_keyboard_for_approve_ban():
def get_main_keyboard():
builder = ReplyKeyboardBuilder()
builder.add(types.KeyboardButton(text="🎤Высказаться"))
builder.add(types.KeyboardButton(text="🎧Послушать"))
# Первая строка: Высказаться и послушать
builder.row(
types.KeyboardButton(text="🎤Высказаться"),
types.KeyboardButton(text="🎧Послушать")
)
# Вторая строка: сбросить прослушивания и узнать эмодзи
builder.row(
types.KeyboardButton(text="🔄Сбросить прослушивания"),
types.KeyboardButton(text="😊Узнать эмодзи")
)
# Третья строка: Вернуться в меню
builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True)
return markup

View File

@@ -1,61 +1,82 @@
import asyncio
from typing import Any, Dict, Union
from typing import Any, Dict, Union, List
from aiogram import BaseMiddleware
from aiogram.types import Message
class AlbumMiddleware(BaseMiddleware):
def __init__(self, latency: Union[int, float] = 0.01): # Уменьшено с 0.1 до 0.01
# Initialize latency and album_data dictionary
"""
Middleware для обработки медиа групп в Telegram.
Собирает все сообщения одной медиа группы и передает их как album в data.
"""
def __init__(self, latency: Union[int, float] = 0.01):
"""
Инициализация middleware.
Args:
latency: Задержка в секундах для сбора всех сообщений медиа группы
"""
super().__init__()
self.latency = latency
self.album_data = {}
self.album_data: Dict[str, Dict[str, List[Message]]] = {}
#
def collect_album_messages(self, event: Message):
def collect_album_messages(self, event: Message) -> int:
"""
Collect messages of the same media group.
Собирает сообщения одной медиа группы.
Args:
event: Сообщение для обработки
Returns:
Количество сообщений в текущей медиа группе
"""
# # Check if media_group_id exists in album_data
if not event.media_group_id:
return 0
if event.media_group_id not in self.album_data:
# # Create a new entry for the media group
self.album_data[event.media_group_id] = {"messages": []}
#
# # Append the new message to the media group
self.album_data[event.media_group_id]["messages"].append(event)
#
# # Return the total number of messages in the current media group
return len(self.album_data[event.media_group_id]["messages"])
#
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Main middleware logic.
Основная логика middleware.
Args:
handler: Обработчик события
event: Событие (сообщение)
data: Данные для передачи в обработчик
Returns:
Результат выполнения обработчика
"""
# # If the event has no media_group_id, pass it to the handler immediately
# Если у события нет media_group_id, передаем его обработчику сразу
if not event.media_group_id:
return await handler(event, data)
#
# # Collect messages of the same media group
# Собираем сообщения одной медиа группы
total_before = self.collect_album_messages(event)
#
# # Wait for a specified latency period
# Ждем указанный период для сбора всех сообщений
await asyncio.sleep(self.latency)
#
# # Check the total number of messages after the latency
# Проверяем количество сообщений после задержки
total_after = len(self.album_data[event.media_group_id]["messages"])
#
# # If new messages were added during the latency, exit
# Если за время задержки добавились новые сообщения, выходим
if total_before != total_after:
return
#
# # Sort the album messages by message_id and add to data
# Сортируем сообщения по message_id и добавляем в data
album_messages = self.album_data[event.media_group_id]["messages"]
album_messages.sort(key=lambda x: x.message_id)
data["album"] = album_messages
#
# # Remove the media group from tracking to free up memory
# Удаляем медиа группу из отслеживания для освобождения памяти
del self.album_data[event.media_group_id]
# # Call the original event handler
# Вызываем оригинальный обработчик события
return await handler(event, data)
#

View File

@@ -13,7 +13,7 @@ except ImportError:
_emoji_lib_available = False
from aiogram import types
from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio
from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio, InputMediaDocument
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
from logs.custom_logger import logger
@@ -145,14 +145,14 @@ async def download_file(message: types.Message, file_id: str):
async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
"""
Создает MediaGroup.
Создает MediaGroup согласно best practices aiogram 3.x.
Args:
album: Album объект из Telegram API.
post_caption: Текст подписи к первому фото.
album: Album объект из Telegram API (список сообщений).
post_caption: Текст подписи к первому медиа файлу.
Returns:
Список InputMediaPhoto (MediaGroup).
Список InputMedia объектов для MediaGroup.
"""
# Экранируем post_caption для безопасного использования в HTML
safe_post_caption = html.escape(str(post_caption)) if post_caption else ""
@@ -162,34 +162,37 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
for i, message in enumerate(album):
if message.photo:
file_id = message.photo[-1].file_id
media_type = 'photo'
# Для фото используем InputMediaPhoto
if i == 0: # Первое фото получает подпись
media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaPhoto(media=file_id))
elif message.video:
file_id = message.video.file_id
media_type = 'video'
# Для видео используем InputMediaVideo
if i == 0: # Первое видео получает подпись
media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaVideo(media=file_id))
elif message.audio:
file_id = message.audio.file_id
media_type = 'audio'
# Для аудио используем InputMediaAudio
if i == 0: # Первое аудио получает подпись
media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaAudio(media=file_id))
elif message.document:
file_id = message.document.file_id
# Для документов используем InputMediaDocument (если поддерживается)
if i == 0: # Первый документ получает подпись
media_group.append(InputMediaDocument(media=file_id, caption=safe_post_caption))
else:
media_group.append(InputMediaDocument(media=file_id))
else:
# Если нет фото, видео или аудио, пропускаем сообщение
# Если нет поддерживаемого медиа, пропускаем сообщение
continue
# Формируем объект MediaGroup с учетом типа медиа
if i == len(album) - 1:
if media_type == 'photo':
media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption))
elif media_type == 'video':
media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption))
elif media_type == 'audio':
media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption))
else:
if media_type == 'photo':
media_group.append(InputMediaPhoto(media=file_id))
elif media_type == 'video':
media_group.append(InputMediaVideo(media=file_id))
elif media_type == 'audio':
media_group.append(InputMediaAudio(media=file_id))
return media_group # Возвращаем MediaGroup
return media_group
async def add_in_db_media_mediagroup(sent_message, bot_db):
@@ -280,26 +283,43 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos
post_content: Список кортежей с путями к файлам.
post_text: Текст подписи.
"""
logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}")
media = []
for file_path in post_content:
for i, file_path in enumerate(post_content):
try:
file = FSInputFile(path=file_path[0])
type = file_path[1]
logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})")
if type == 'video':
media.append(types.InputMediaVideo(media=file))
if type == 'photo':
elif type == 'photo':
media.append(types.InputMediaPhoto(media=file))
else:
logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}")
except FileNotFoundError:
logger.error(f"Файл не найден: {file_path[0]}")
return
except Exception as e:
logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}")
return
logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки")
# Добавляем подпись к последнему файлу
if media:
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
media[-1].caption = safe_post_text
logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}")
await bot.send_media_group(chat_id=chat_id, media=media)
try:
await bot.send_media_group(chat_id=chat_id, media=media)
logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}")
except Exception as e:
logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}")
raise
async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None):
@@ -599,7 +619,7 @@ async def update_user_info(source: str, message: types.Message):
@db_query_time("check_emoji_for_user", "users", "select")
async def check_user_emoji(message: types.Message):
user_id = message.from_user.id
user_emoji = await BotDB.get_stickers_info(user_id=user_id)
user_emoji = await BotDB.get_user_emoji(user_id=user_id)
if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""):
user_emoji = await get_random_emoji()
await BotDB.update_user_emoji(user_id=user_id, emoji=user_emoji)

View File

@@ -1,142 +0,0 @@
# Тесты для PostRepository
Этот документ описывает тесты для `PostRepository` - репозитория для работы с постами из Telegram.
## Структура тестов
### 1. `test_post_repository.py` - Unit тесты
Содержит модульные тесты с моками для всех методов `PostRepository`:
- **`test_create_tables`** - тест создания таблиц БД
- **`test_add_post_with_date`** - тест добавления поста с датой
- **`test_add_post_without_date`** - тест добавления поста без даты (автогенерация)
- **`test_add_post_logs_correctly`** - тест логирования при добавлении поста
- **`test_update_helper_message`** - тест обновления helper сообщения
- **`test_add_post_content_success`** - тест успешного добавления контента
- **`test_add_post_content_exception`** - тест обработки исключений
- **`test_get_post_content_by_helper_id`** - тест получения контента по helper ID
- **`test_get_post_text_by_helper_id_found`** - тест получения текста поста (найден)
- **`test_get_post_text_by_helper_id_not_found`** - тест получения текста поста (не найден)
- **`test_get_post_ids_by_helper_id`** - тест получения ID сообщений
- **`test_get_author_id_by_message_id_found`** - тест получения ID автора по message ID (найден)
- **`test_get_author_id_by_message_id_not_found`** - тест получения ID автора по message ID (не найден)
- **`test_get_author_id_by_helper_message_id_found`** - тест получения ID автора по helper message ID (найден)
- **`test_get_author_id_by_helper_message_id_not_found`** - тест получения ID автора по helper message ID (не найден)
- **`test_create_tables_logs_success`** - тест логирования успешного создания таблиц
### 2. `test_post_repository_integration.py` - Интеграционные тесты
Содержит тесты с реальной базой данных SQLite:
- **`test_create_tables_integration`** - интеграционный тест создания таблиц
- **`test_add_post_integration`** - интеграционный тест добавления поста
- **`test_add_post_without_date_integration`** - интеграционный тест добавления поста без даты
- **`test_update_helper_message_integration`** - интеграционный тест обновления helper сообщения
- **`test_add_post_content_integration`** - интеграционный тест добавления контента поста
- **`test_add_post_content_with_helper_message_integration`** - интеграционный тест добавления контента с helper сообщением
- **`test_get_post_text_by_helper_id_integration`** - интеграционный тест получения текста поста
- **`test_get_post_text_by_helper_id_not_found_integration`** - интеграционный тест получения текста несуществующего поста
- **`test_get_post_ids_by_helper_id_integration`** - интеграционный тест получения ID сообщений
- **`test_get_author_id_by_message_id_integration`** - интеграционный тест получения ID автора по message ID
- **`test_get_author_id_by_message_id_not_found_integration`** - интеграционный тест получения ID автора несуществующего поста
- **`test_get_author_id_by_helper_message_id_integration`** - интеграционный тест получения ID автора по helper message ID
- **`test_get_author_id_by_helper_message_id_not_found_integration`** - интеграционный тест получения ID автора несуществующего helper сообщения
- **`test_multiple_posts_integration`** - интеграционный тест работы с несколькими постами
- **`test_post_content_relationships_integration`** - интеграционный тест связей между постами и контентом
### 3. `conftest_post_repository.py` - Общие фикстуры
Содержит фикстуры для всех тестов:
- **`mock_post_repository`** - мок PostRepository для unit тестов
- **`sample_telegram_post`** - тестовый объект TelegramPost
- **`sample_telegram_post_with_helper`** - тестовый объект TelegramPost с helper сообщением
- **`sample_telegram_post_no_date`** - тестовый объект TelegramPost без даты
- **`sample_post_content`** - тестовый объект PostContent
- **`sample_message_content_link`** - тестовый объект MessageContentLink
- **`mock_db_execute_query`** - мок для _execute_query
- **`mock_db_execute_query_with_result`** - мок для _execute_query_with_result
- **`mock_logger`** - мок для logger
- **`temp_db_file`** - временный файл БД для интеграционных тестов
- **`real_post_repository`** - реальный PostRepository с временной БД
- **`sample_posts_batch`** - набор тестовых постов для batch тестов
- **`sample_content_batch`** - набор тестового контента для batch тестов
- **`mock_database_connection`** - мок для DatabaseConnection
- **`sample_helper_message_ids`** - набор тестовых helper message ID
- **`sample_message_ids`** - набор тестовых message ID
- **`sample_author_ids`** - набор тестовых author ID
- **`mock_sql_queries`** - мок для SQL запросов
## Запуск тестов
### Запуск всех тестов для PostRepository:
```bash
pytest tests/test_post_repository.py -v
pytest tests/test_post_repository_integration.py -v
```
### Запуск с покрытием:
```bash
pytest tests/test_post_repository.py --cov=database.repositories.post_repository --cov-report=html
pytest tests/test_post_repository_integration.py --cov=database.repositories.post_repository --cov-report=html
```
### Запуск конкретного теста:
```bash
pytest tests/test_post_repository.py::TestPostRepository::test_add_post_with_date -v
```
## Требования
- `pytest` - фреймворк для тестирования
- `pytest-asyncio` - поддержка асинхронных тестов
- `pytest-cov` - для измерения покрытия кода (опционально)
## Особенности тестирования
### Unit тесты
- Используют моки для изоляции тестируемого кода
- Проверяют логику методов без зависимости от БД
- Быстрые и надежные
### Интеграционные тесты
- Используют реальную SQLite БД в памяти
- Проверяют взаимодействие с БД
- Создают временные файлы БД для каждого теста
- Автоматически очищают ресурсы после тестов
### Фикстуры
- Переиспользуемые объекты для тестов
- Автоматическая очистка ресурсов
- Разделение на unit и integration фикстуры
## Покрытие тестами
Тесты покрывают все публичные методы `PostRepository`:
-`create_tables()` - создание таблиц БД
-`add_post()` - добавление поста
-`update_helper_message()` - обновление helper сообщения
-`add_post_content()` - добавление контента поста
-`get_post_content_by_helper_id()` - получение контента по helper ID
-`get_post_text_by_helper_id()` - получение текста поста по helper ID
-`get_post_ids_by_helper_id()` - получение ID сообщений по helper ID
-`get_author_id_by_message_id()` - получение ID автора по message ID
-`get_author_id_by_helper_message_id()` - получение ID автора по helper message ID
## Добавление новых тестов
При добавлении новых методов в `PostRepository`:
1. Добавьте unit тест в `test_post_repository.py`
2. Добавьте интеграционный тест в `test_post_repository_integration.py`
3. Добавьте необходимые фикстуры в `conftest_post_repository.py`
4. Обновите этот README файл
## Отладка тестов
Для отладки тестов используйте:
```bash
pytest tests/test_post_repository.py -v -s --tb=long
```
Флаг `-s` позволяет видеть print statements, `--tb=long` показывает полный traceback ошибок.