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