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 # Other files
voice_users/ voice_users/
files/

View File

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

View File

@@ -1,5 +1,5 @@
import html
from datetime import datetime, timedelta from datetime import datetime, timedelta
import html
from typing import Dict, Any from typing import Dict, Any
from aiogram import Bot from aiogram import Bot
@@ -42,9 +42,14 @@ class PostPublishService:
async def publish_post(self, call: CallbackQuery) -> None: 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 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) await self._publish_text_post(call)
elif content_type == CONTENT_TYPE_PHOTO: elif content_type == CONTENT_TYPE_PHOTO:
await self._publish_photo_post(call) await self._publish_photo_post(call)
@@ -56,8 +61,6 @@ class PostPublishService:
await self._publish_audio_post(call) await self._publish_audio_post(call)
elif content_type == CONTENT_TYPE_VOICE: elif content_type == CONTENT_TYPE_VOICE:
await self._publish_voice_post(call) await self._publish_voice_post(call)
elif call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._publish_media_group(call)
else: else:
raise PublishError(f"Неподдерживаемый тип контента: {content_type}") raise PublishError(f"Неподдерживаемый тип контента: {content_type}")
@@ -115,51 +118,111 @@ class PostPublishService:
async def _publish_media_group(self, call: CallbackQuery) -> None: 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) logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}")
pre_text = await self.db.get_post_text_from_telegram_by_last_id(call.message.message_id) try:
post_text = html.escape(str(pre_text)) # call.message.message_id - это ID helper сообщения
author_id = await self._get_author_id_for_media_group(call.message.message_id) helper_message_id = 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) # Получаем контент медиагруппы по 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) 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: 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 content_type = call.message.content_type
if (content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP) or \ if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
content_type in [CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]: CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
logger.debug(f"Отклоняю одиночный пост типа: {content_type}")
await self._decline_single_post(call) await self._decline_single_post(call)
elif call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._decline_media_group(call)
else: else:
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}") raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
async def _decline_single_post(self, call: CallbackQuery) -> None: 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) 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) await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
try: try:
logger.debug(f"Отправляю уведомление об отклонении автору {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
raise raise
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
async def _decline_media_group(self, call: CallbackQuery) -> None: 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) 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) 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) 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) await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids)
try: try:
logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}")
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e: except Exception as e:
if str(e) == ERROR_BOT_BLOCKED: if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"Пользователь {author_id} заблокировал бота")
raise UserBlockedBotError("Пользователь заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}")
raise raise
async def _get_author_id(self, message_id: int) -> int: 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: async def _get_author_id_for_media_group(self, message_id: int) -> int:
"""Получение ID автора для медиагруппы""" """Получение ID автора для медиагруппы"""
# Сначала пытаемся найти автора по helper_message_id
author_id = await self.db.get_author_id_by_helper_message_id(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: if not author_id:
raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}") raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}")
return author_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: 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) 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) #message_ids = post_ids.copy()
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) 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: try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e: except Exception as e:

View File

@@ -272,6 +272,16 @@ class PostService:
if album and album[0].caption: if album and album[0].caption:
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) 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 = 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 self.settings.group_for_posts, message, media_group, self.db
@@ -279,11 +289,24 @@ class PostService:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
# Создаем 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, "^", 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( 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") @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: async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
"""Process post based on content type""" """Process post based on content type"""
first_name = get_first_name(message) first_name = get_first_name(message)
# TODO: Бесит меня этот функционал
if message.media_group_id is not None: if message.media_group_id is not None:
safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма" safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма"
await send_text_message( await send_text_message(

View File

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

View File

@@ -1,5 +1,6 @@
import random import random
import asyncio import asyncio
import traceback
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple 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: async def download_and_save_audio(self, bot, message, file_name: str) -> None:
"""Скачать и сохранить аудио файл""" """Скачать и сохранить аудио файл"""
try: try:
logger.info(f"Начинаем скачивание и сохранение аудио: {file_name}")
# Проверяем наличие голосового сообщения # Проверяем наличие голосового сообщения
if not message or not message.voice: if not message or not message.voice:
logger.error("Сообщение или голосовое сообщение не найдено")
raise FileOperationError("Сообщение или голосовое сообщение не найдено") raise FileOperationError("Сообщение или голосовое сообщение не найдено")
file_id = message.voice.file_id file_id = message.voice.file_id
logger.info(f"Получен file_id: {file_id}")
file_info = await bot.get_file(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) 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 import os
os.makedirs(VOICE_USERS_DIR, exist_ok=True) 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()) new_file.write(downloaded_file.read())
logger.info(f"Файл успешно сохранен: {file_path}")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при скачивании и сохранении аудио: {e}") logger.error(f"Ошибка при скачивании и сохранении аудио: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise FileOperationError(f"Не удалось скачать и сохранить аудио: {e}") 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.services import VoiceBotService
from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe 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.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 from helper_bot.handlers.private.constants import BUTTON_TEXTS
class VoiceHandlers: class VoiceHandlers:
def __init__(self, db, settings): def __init__(self, db, settings):
self.db = db self.db = db.get_db() if hasattr(db, 'get_db') else db
self.settings = settings self.settings = settings
self.router = Router() self.router = Router()
self._setup_handlers() self._setup_handlers()
@@ -94,6 +96,25 @@ class VoiceHandlers:
F.text == BTN_LISTEN 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")): async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")):
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры""" """Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
try: try:
@@ -180,6 +201,19 @@ class VoiceHandlers:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при отметке получения приветствия: {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( async def refresh_listen_function(
self, self,
@@ -334,7 +368,7 @@ class VoiceHandlers:
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id) 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( await message.answer(
text=f'Осталось непрослушанных: <b>{remaining_count}</b>', text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup reply_markup=markup

View File

@@ -25,13 +25,13 @@ def get_reply_keyboard_for_post():
@track_time("get_reply_keyboard", "keyboard_service") @track_time("get_reply_keyboard", "keyboard_service")
@track_errors("keyboard_service", "get_reply_keyboard") @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 = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост")) builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
builder.row(types.KeyboardButton(text="📩Связаться с админами")) builder.row(types.KeyboardButton(text="📩Связаться с админами"))
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="🤪Хочу стикеры")) builder.row(types.KeyboardButton(text="🤪Хочу стикеры"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
@@ -175,8 +175,18 @@ def create_keyboard_for_approve_ban():
def get_main_keyboard(): def get_main_keyboard():
builder = ReplyKeyboardBuilder() 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) markup = builder.as_markup(resize_keyboard=True)
return markup return markup

View File

@@ -1,61 +1,82 @@
import asyncio import asyncio
from typing import Any, Dict, Union from typing import Any, Dict, Union, List
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message from aiogram.types import Message
class AlbumMiddleware(BaseMiddleware): class AlbumMiddleware(BaseMiddleware):
def __init__(self, latency: Union[int, float] = 0.01): # Уменьшено с 0.1 до 0.01 """
# Initialize latency and album_data dictionary Middleware для обработки медиа групп в Telegram.
self.latency = latency Собирает все сообщения одной медиа группы и передает их как album в data.
self.album_data = {} """
# def __init__(self, latency: Union[int, float] = 0.01):
def collect_album_messages(self, event: Message):
""" """
Collect messages of the same media group. Инициализация middleware.
Args:
latency: Задержка в секундах для сбора всех сообщений медиа группы
""" """
# # Check if media_group_id exists in album_data super().__init__()
self.latency = latency
self.album_data: Dict[str, Dict[str, List[Message]]] = {}
def collect_album_messages(self, event: Message) -> int:
"""
Собирает сообщения одной медиа группы.
Args:
event: Сообщение для обработки
Returns:
Количество сообщений в текущей медиа группе
"""
if not event.media_group_id:
return 0
if event.media_group_id not in self.album_data: 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": []} 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) 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"]) return len(self.album_data[event.media_group_id]["messages"])
#
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: 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: if not event.media_group_id:
return await handler(event, data) return await handler(event, data)
#
# # Collect messages of the same media group # Собираем сообщения одной медиа группы
total_before = self.collect_album_messages(event) total_before = self.collect_album_messages(event)
#
# # Wait for a specified latency period # Ждем указанный период для сбора всех сообщений
await asyncio.sleep(self.latency) 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"]) 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: if total_before != total_after:
return 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 = self.album_data[event.media_group_id]["messages"]
album_messages.sort(key=lambda x: x.message_id) album_messages.sort(key=lambda x: x.message_id)
data["album"] = album_messages data["album"] = album_messages
#
# # Remove the media group from tracking to free up memory # Удаляем медиа группу из отслеживания для освобождения памяти
del self.album_data[event.media_group_id] del self.album_data[event.media_group_id]
# # Call the original event handler
# Вызываем оригинальный обработчик события
return await handler(event, data) return await handler(event, data)
#

View File

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