diff --git a/bot/handlers/commands/users/report.py b/bot/handlers/commands/users/report.py new file mode 100644 index 0000000..f5e4a64 --- /dev/null +++ b/bot/handlers/commands/users/report.py @@ -0,0 +1,457 @@ +""" +Обработчики команды /report для пользователей +""" +from datetime import datetime + +from aiogram import Router, F +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, User +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from bot.filters.admin import IsAdmin +from configs import settings, COMMANDS +from database import get_manager +from middleware.loggers import logger + +__all__ = ("router",) + +router: Router = Router(name="report_router") + + +# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= + +def format_user(user: User) -> str: + """Форматирует информацию о пользователе.""" + if not user: + return "Unknown User" + + name_parts = [] + if user.first_name: + name_parts.append(user.first_name) + if user.last_name: + name_parts.append(user.last_name) + + full_name = " ".join(name_parts) if name_parts else "No Name" + + if user.username: + return f"{full_name} (@{user.username})" + return full_name + + +def format_datetime(dt: datetime) -> str: + """Форматирует datetime""" + return dt.strftime("%d.%m.%Y %H:%M:%S") + + +def truncate_text(text: str, max_length: int = 200) -> str: + """Обрезает текст до указанной длины""" + if len(text) <= max_length: + return text + return text[:max_length] + "..." + + +def get_report_keyboard( + chat_id: int, + message_id: int, + reported_user_id: int, + report_id: str, + message_thread_id: int | None = None +) -> InlineKeyboardBuilder: + """ + Создает клавиатуру для репорта. + + Args: + chat_id: ID чата, где было сообщение + message_id: ID сообщения + reported_user_id: ID пользователя, на которого пожаловались + report_id: Уникальный ID репорта + message_thread_id: ID топика (если есть) + """ + ikb = InlineKeyboardBuilder() + + thread_id = message_thread_id if message_thread_id is not None else 0 + + ikb.button( + text="🚫 Забанить", + callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}:{thread_id}" + ) + ikb.button( + text="🗑 Удалить", + callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}:{thread_id}" + ) + ikb.button( + text="✅ Закрыть", + callback_data=f"report:close:{report_id}" + ) + + ikb.adjust(2, 1) + return ikb + + +def generate_report_id() -> str: + """Генерирует уникальный ID репорта""" + return f"{int(datetime.now().timestamp() * 1000)}" + + +# ================= КОМАНДА РЕПОРТА ================= + +@router.message(Command(*COMMANDS.get("report", ["report"]), prefix=settings.PREFIX, ignore_case=True)) +async def report_cmd(message: Message) -> None: + """ + Отправляет жалобу на сообщение администраторам. + + Использование: + /report — в ответ на сообщение + /report <причина> — в ответ на сообщение с указанием причины + """ + if not message.reply_to_message: + await message.answer( + "❌ Используйте команду в ответ на сообщение\n\n" + "Как использовать:\n" + "1. Ответьте на сообщение нарушителя\n" + "2. Напишите /report или /report причина\n\n" + "Пример: /report спам", + parse_mode="HTML", + ) + return + + reported_message = message.reply_to_message + reported_user = reported_message.from_user + reporter = message.from_user + + if not reported_user or not reporter: + await message.answer( + "❌ Ошибка получения данных пользователя", + parse_mode="HTML", + ) + return + + if reported_user.id == reporter.id: + await message.answer( + "⚠️ Нельзя пожаловаться на самого себя", + parse_mode="HTML", + ) + return + + if reported_user.is_bot: + await message.answer( + "⚠️ Нельзя пожаловаться на бота", + parse_mode="HTML", + ) + return + + manager = get_manager() + is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID + + if is_admin: + await message.answer( + "⚠️ Нельзя пожаловаться на администратора", + parse_mode="HTML", + ) + return + + parts = (message.text or "").split(maxsplit=1) + reason = parts[1] if len(parts) > 1 else "Не указана" + + report_id = generate_report_id() + + # thread/topic исходного сообщения (если репортят из топика) + original_message_thread_id = reported_message.message_thread_id + + report_text = "🚨 НОВЫЙ РЕПОРТ\n\n" + report_text += f"👤 От: {format_user(reporter)} ({reporter.id})\n" + report_text += f"⚠️ На: {format_user(reported_user)} ({reported_user.id})\n\n" + + chat_title = message.chat.title if message.chat.title else "Личные сообщения" + report_text += f"💬 Чат: {chat_title}\n" + report_text += f"🆔 Chat ID: {message.chat.id}\n" + + if original_message_thread_id: + report_text += f"📌 Topic ID: {original_message_thread_id}\n" + report_text += "\n" + + report_text += f"📝 Причина: {reason}\n\n" + report_text += "📄 Текст сообщения:\n" + + message_content = None + if reported_message.text: + truncated_text = truncate_text(reported_message.text, max_length=300) + report_text += f"{truncated_text}\n\n" + message_content = reported_message.text + elif reported_message.caption: + truncated_caption = truncate_text(reported_message.caption, max_length=300) + report_text += f"{truncated_caption}\n\n" + message_content = reported_message.caption + else: + report_text += f"[{reported_message.content_type}]\n\n" + + report_text += f"🕐 Время: {format_datetime(datetime.now())}\n" + report_text += f"🔗 Message ID: {reported_message.message_id}\n\n" + report_text += f"💡 ID репорта: {report_id}" + + keyboard = get_report_keyboard( + chat_id=message.chat.id, + message_id=reported_message.message_id, + reported_user_id=reported_user.id, + report_id=report_id, + message_thread_id=original_message_thread_id + ) + + try: + report_chat_id = settings.REPORT_CHAT_ID + report_thread_id = settings.REPORT_THREAD_ID + + # Нормализуем: 0 считаем как "без топика" + if report_thread_id == 0: + report_thread_id = None + + if report_chat_id: + send_params = { + "chat_id": report_chat_id, + "text": report_text, + "parse_mode": "HTML", + "reply_markup": keyboard.as_markup(), + } + if report_thread_id is not None: + send_params["message_thread_id"] = report_thread_id # отправка в конкретный топик + + await message.bot.send_message(**send_params) + else: + sent_count = 0 + for owner_id in settings.OWNER_ID: + try: + await message.bot.send_message( + chat_id=owner_id, + text=report_text, + parse_mode="HTML", + reply_markup=keyboard.as_markup() + ) + sent_count += 1 + except Exception as e: + logger.error(f"Ошибка отправки репорта владельцу {owner_id}: {e}", log_type="REPORT") + + if sent_count == 0: + raise Exception("Не удалось отправить репорт ни одному владельцу") + + await manager.log_report( + report_id=report_id, + reporter_id=reporter.id, + reporter_username=reporter.username, + reported_user_id=reported_user.id, + reported_username=reported_user.username, + chat_id=message.chat.id, + chat_title=chat_title, + message_id=reported_message.message_id, + message_thread_id=original_message_thread_id, + message_text=message_content, + reason=reason + ) + + await message.answer( + "✅ Жалоба отправлена администраторам\n\n" + "Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.", + parse_mode="HTML", + ) + + logger.info( + f"Репорт #{report_id}: {reporter.id} → {reported_user.id} в чате {message.chat.id}" + + (f" (топик {original_message_thread_id})" if original_message_thread_id else ""), + log_type="REPORT" + ) + + except Exception as e: + logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT") + await message.answer( + "❌ Ошибка отправки жалобы\n\n" + "Попробуйте позже или обратитесь к администратору напрямую.", + parse_mode="HTML", + ) + + +# ================= ОБРАБОТЧИКИ КНОПОК ================= + +@router.callback_query(F.data.startswith("report:ban:"), IsAdmin()) +async def report_ban_callback(callback: CallbackQuery) -> None: + """Обрабатывает нажатие кнопки 'Забанить'""" + manager = get_manager() + + try: + parts = (callback.data or "").split(":") + chat_id = int(parts[2]) + user_id = int(parts[3]) + report_id = parts[4] + + try: + await callback.bot.ban_chat_member(chat_id=chat_id, user_id=user_id) + + await manager.repo.update_report_status( + report_id=report_id, + status="banned", + processed_by=callback.from_user.id + ) + + admin_name = format_user(callback.from_user) + updated_text = (callback.message.text if callback.message else "") + f"\n\n✅ Пользователь забанен ({admin_name})" + + if callback.message: + await callback.message.edit_text(text=updated_text, parse_mode="HTML") + + await callback.answer("✅ Пользователь забанен", show_alert=True) + + logger.info( + f"Репорт #{report_id}: пользователь {user_id} забанен админом {callback.from_user.id}", + log_type="REPORT" + ) + + except TelegramBadRequest as e: + await callback.answer(f"❌ Ошибка бана: {str(e)}", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка обработки бана из репорта: {e}", log_type="REPORT") + await callback.answer("❌ Ошибка выполнения", show_alert=True) + + +@router.callback_query(F.data.startswith("report:delete:"), IsAdmin()) +async def report_delete_callback(callback: CallbackQuery) -> None: + """Обрабатывает нажатие кнопки 'Удалить'""" + manager = get_manager() + + try: + parts = (callback.data or "").split(":") + chat_id = int(parts[2]) + message_id = int(parts[3]) + report_id = parts[4] + + try: + await callback.bot.delete_message(chat_id=chat_id, message_id=message_id) + + await manager.repo.update_report_status( + report_id=report_id, + status="deleted", + processed_by=callback.from_user.id + ) + + admin_name = format_user(callback.from_user) + updated_text = (callback.message.text if callback.message else "") + f"\n\n🗑 Сообщение удалено ({admin_name})" + + if callback.message: + await callback.message.edit_text(text=updated_text, parse_mode="HTML") + + await callback.answer("✅ Сообщение удалено", show_alert=True) + + logger.info( + f"Репорт #{report_id}: сообщение {message_id} удалено админом {callback.from_user.id}", + log_type="REPORT" + ) + + except TelegramBadRequest as e: + await callback.answer(f"❌ Ошибка удаления: {str(e)}", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка удаления из репорта: {e}", log_type="REPORT") + await callback.answer("❌ Ошибка выполнения", show_alert=True) + + +@router.callback_query(F.data.startswith("report:close:"), IsAdmin()) +async def report_close_callback(callback: CallbackQuery) -> None: + """Обрабатывает нажатие кнопки 'Закрыть' (и удаляет сообщение репорта)""" + manager = get_manager() + + try: + parts = (callback.data or "").split(":") + report_id = parts[2] + + await manager.repo.update_report_status( + report_id=report_id, + status="closed", + processed_by=callback.from_user.id + ) + + await callback.answer("✅ Репорт закрыт") + + # Удаляем сообщение с репортом в админ-чате/топике + if callback.message: + try: + await callback.message.delete() + except TelegramBadRequest as e: + logger.warning(f"Не удалось удалить сообщение репорта: {e}", log_type="REPORT") + + logger.info( + f"Репорт #{report_id} закрыт админом {callback.from_user.id}", + log_type="REPORT" + ) + + except Exception as e: + logger.error(f"Ошибка закрытия репорта: {e}", log_type="REPORT") + await callback.answer("❌ Ошибка выполнения", show_alert=True) + + +# ================= ДОПОЛНИТЕЛЬНЫЕ КОМАНДЫ ================= + +@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True)) +async def report_help_cmd(message: Message) -> None: + """Показывает справку по системе репортов.""" + text = ( + "🚨 СИСТЕМА РЕПОРТОВ\n\n" + "Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n" + "📝 Как пожаловаться:\n" + "1. Ответьте на сообщение нарушителя\n" + "2. Напишите /report\n" + "3. Можно указать причину: /report спам\n\n" + "✅ Примеры:\n" + "• /report — жалоба без причины\n" + "• /report спам — жалоба на спам\n" + "• /report оскорбления — жалоба на оскорбления\n\n" + "⚠️ Важно:\n" + "├─ Нельзя пожаловаться на себя\n" + "├─ Нельзя пожаловаться на ботов\n" + "├─ Нельзя пожаловаться на администраторов\n" + "└─ Ложные жалобы могут привести к бану\n\n" + "💡 Администраторы получат уведомление и примут меры" + ) + + await message.answer(text, parse_mode="HTML") + + +@router.message( + Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), + IsAdmin() +) +async def report_stats_cmd(message: Message) -> None: + """Показывает статистику по репортам (для админов)""" + manager = get_manager() + + stats = await manager.repo.get_report_stats() + top_reporters = await manager.repo.get_top_reporters(limit=5) + top_reported = await manager.repo.get_top_reported_users(limit=5) + + if not stats: + await message.answer("❌ Ошибка получения статистики", parse_mode="HTML") + return + + text = "📊 СТАТИСТИКА РЕПОРТОВ\n\n" + text += "📈 Общая статистика:\n" + text += f"├─ Всего репортов: {stats.get('total', 0)}\n" + text += f"├─ В ожидании: {stats.get('pending', 0)}\n" + text += f"├─ Закрыто: {stats.get('closed', 0)}\n" + text += f"├─ Забанено: {stats.get('banned', 0)}\n" + text += f"└─ Удалено: {stats.get('deleted', 0)}\n\n" + + if top_reporters: + text += "👥 Топ жалобщиков:\n" + for i, (user_id, username, count) in enumerate(top_reporters, 1): + username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}") + text += f"{i}. {username_display} — {count} реп.\n" + text += "\n" + + if top_reported: + text += "⚠️ Топ нарушителей:\n" + for i, (user_id, username, count) in enumerate(top_reported, 1): + username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}") + text += f"{i}. {username_display} — {count} жалоб\n" + text += "\n" + + text += f"🕐 Обновлено: {format_datetime(datetime.now())}" + + await message.answer(text, parse_mode="HTML")