diff --git a/bot/handlers/commands/users/stats.py b/bot/handlers/commands/users/stats.py
new file mode 100644
index 0000000..51bda9f
--- /dev/null
+++ b/bot/handlers/commands/users/stats.py
@@ -0,0 +1,589 @@
+"""
+Обработчики команды статистики
+"""
+from datetime import datetime
+from aiogram import Router, F
+from aiogram.filters import Command
+from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
+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
+from bot.utils.decorators import log_action
+
+__all__ = ("router",)
+
+router: Router = Router(name="stats_router")
+
+
+# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
+
+def format_number(num: int) -> str:
+ """Форматирует большие числа с разделителями"""
+ return f"{num:,}".replace(",", " ")
+
+
+def create_text_bar(value: int, max_value: int, length: int = 10) -> str:
+ """Создает текстовую полоску прогресса"""
+ if max_value == 0:
+ return "░" * length
+
+ filled = int((value / max_value) * length)
+ filled = max(0, min(filled, length))
+ empty = length - filled
+
+ return "█" * filled + "░" * empty
+
+
+def format_datetime(dt: datetime) -> str:
+ """Форматирует datetime в читабельный формат"""
+ return dt.strftime("%d.%m.%Y %H:%M")
+
+
+def format_time_remaining(minutes: int) -> str:
+ """
+ Форматирует оставшееся время в читабельный формат.
+
+ Args:
+ minutes: Количество минут
+
+ Returns:
+ Отформатированная строка времени
+ """
+ if minutes <= 0:
+ return "истёк"
+ elif minutes < 60:
+ return f"{minutes} мин"
+ elif minutes < 1440: # < 24 часов
+ hours = minutes // 60
+ mins = minutes % 60
+ if mins > 0:
+ return f"{hours}ч {mins}м"
+ return f"{hours}ч"
+ else: # >= 24 часов
+ days = minutes // 1440
+ hours = (minutes % 1440) // 60
+ if hours > 0:
+ return f"{days}д {hours}ч"
+ return f"{days}д"
+
+
+def get_stats_keyboard():
+ """Клавиатура для статистики"""
+ ikb = InlineKeyboardBuilder()
+ ikb.button(text="🔄 Обновить", callback_data="stats:refresh")
+ ikb.button(text="📊 Детали", callback_data="stats:details")
+ ikb.button(text="🏆 Топ-спамеры", callback_data="stats:top_spammers")
+ ikb.button(text="🔤 Топ-слова", callback_data="stats:top_words")
+ ikb.button(text="🚀 Назад", callback_data="start")
+ ikb.adjust(2, 2, 1)
+ return ikb.as_markup()
+
+
+# ================= ОСНОВНАЯ СТАТИСТИКА =================
+
+@router.callback_query(F.data == "stats:refresh")
+@router.callback_query(F.data == "stats")
+@router.message(Command(*COMMANDS.get("stats", ["stats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
+@log_action(action_name="VIEW_STATS")
+async def stats_cmd(update: Message | CallbackQuery) -> None:
+ """
+ Показывает общую статистику работы бота.
+
+ Включает:
+ - Общее количество удалений
+ - Активные режимы
+ - Статистику банвордов
+ - Топ спамеров
+
+ Использование: /stats
+ """
+ # Определяем тип update
+ if isinstance(update, CallbackQuery):
+ message = update.message
+ is_callback = True
+ else:
+ message = update
+ is_callback = False
+
+ manager = get_manager()
+
+ try:
+ # Получаем данные
+ stats = await manager.get_stats()
+ data = await manager.get_all_words_list()
+ top_spammers = await manager.get_top_spammers(limit=5)
+
+ # Проверяем активные режимы
+ is_silence = await manager.is_silence_active()
+ is_conflict = await manager.is_conflict_active()
+
+ # === ФОРМИРУЕМ ВЫВОД ===
+
+ output = "📊 СТАТИСТИКА PRIMOGUARD\n\n"
+
+ # Общая информация
+ total_deletions = stats.get('total_deletions', 0)
+ output += f"🗑 Всего удалений: {format_number(total_deletions)}\n\n"
+
+ # Активные режимы
+ if is_silence or is_conflict:
+ output += "🔴 АКТИВНЫЕ РЕЖИМЫ:\n\n"
+
+ if is_silence:
+ silence_until_str = await manager.repo.get_setting("silence_until")
+ silence_until = datetime.fromtimestamp(float(silence_until_str))
+ time_left_seconds = (silence_until - datetime.now()).total_seconds()
+ time_left_minutes = int(time_left_seconds / 60)
+
+ output += f"🔇 Режим тишины\n"
+ output += f"├─ ⏱ Осталось: {format_time_remaining(time_left_minutes)}\n"
+ output += f"└─ 🕐 До: {format_datetime(silence_until)}\n"
+
+ if is_conflict:
+ output += "│\n"
+
+ if is_conflict:
+ conflict_until_str = await manager.repo.get_setting("conflict_until")
+ conflict_until = datetime.fromtimestamp(float(conflict_until_str))
+ time_left_seconds = (conflict_until - datetime.now()).total_seconds()
+ time_left_minutes = int(time_left_seconds / 60)
+
+ conflict_words_count = len(data.get('conflict_substring', set()))
+ conflict_lemmas_count = len(data.get('conflict_lemma', set()))
+ total_conflict = conflict_words_count + conflict_lemmas_count
+
+ output += f"⚔️ Режим антиконфликта\n"
+ output += f"├─ ⏱ Осталось: {format_time_remaining(time_left_minutes)}\n"
+ output += f"├─ 🕐 До: {format_datetime(conflict_until)}\n"
+ output += f"└─ 📊 Правил: {total_conflict}\n"
+
+ output += "\n"
+
+ # Статистика правил
+ total_rules = (
+ len(data.get('substring', set())) +
+ len(data.get('lemma', set())) +
+ len(data.get('part', set())) +
+ len(data.get('temp_substring', set())) +
+ len(data.get('temp_lemma', set())) +
+ len(data.get('conflict_substring', set())) +
+ len(data.get('conflict_lemma', set()))
+ )
+
+ output += f"📋 Правила модерации:\n"
+ output += f"├─ Всего правил: {total_rules}\n"
+ output += f"├─ Постоянные: {len(data.get('substring', set())) + len(data.get('lemma', set())) + len(data.get('part', set()))}\n"
+ output += f"├─ Временные: {len(data.get('temp_substring', set())) + len(data.get('temp_lemma', set()))}\n"
+ output += f"├─ Конфликтные: {len(data.get('conflict_substring', set())) + len(data.get('conflict_lemma', set()))}\n"
+ output += f"└─ Исключения: {len(data.get('whitelist', set()))}\n\n"
+
+ # Топ-5 спамеров
+ if top_spammers:
+ output += "🏆 Топ-5 спамеров:\n"
+ max_count = top_spammers[0][1] if top_spammers else 1
+
+ for idx, (user_id, count) in enumerate(top_spammers, 1):
+ bar = create_text_bar(count, max_count, length=8)
+ output += f'{idx}. {user_id} — {count} [{bar}]\n'
+
+ output += "\n"
+ else:
+ output += "🏆 Топ-5 спамеров:\n"
+ output += "└─ Нет данных\n\n"
+
+ # Администраторы
+ admins_count = len(settings.OWNER_ID) + len(data.get('admins', set()))
+ output += f"👥 Администраторов: {admins_count}\n\n"
+
+ # Подсказка
+ output += "💡 Используйте кнопки для детальной информации"
+
+ # Клавиатура
+ keyboard = get_stats_keyboard()
+
+ # Отправка
+ if is_callback:
+ await message.edit_text(
+ text=output,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+ await update.answer("✅ Статистика обновлена")
+ else:
+ await message.answer(
+ text=output,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка получения статистики: {e}", log_type="STATS")
+
+ error_text = "❌ Ошибка загрузки статистики\n\nПопробуйте позже"
+
+ if is_callback:
+ await update.answer("❌ Ошибка загрузки", show_alert=True)
+ else:
+ await message.answer(error_text, parse_mode="HTML")
+
+
+# ================= ДЕТАЛЬНАЯ СТАТИСТИКА =================
+
+@router.callback_query(F.data == "stats:details")
+@log_action(action_name="VIEW_DETAILED_STATS")
+async def stats_details_callback(callback: CallbackQuery) -> None:
+ """Показывает детальную статистику"""
+ manager = get_manager()
+
+ try:
+ stats = await manager.get_stats()
+ data = await manager.get_all_words_list()
+
+ output = "📊 ДЕТАЛЬНАЯ СТАТИСТИКА\n\n"
+
+ # Подробная статистика удалений
+ total_deletions = stats.get('total_deletions', 0)
+ output += f"🗑 Удаления сообщений:\n"
+ output += f"├─ Всего: {format_number(total_deletions)}\n"
+ output += "\n"
+
+ # Активные режимы (детально)
+ is_silence = await manager.is_silence_active()
+ is_conflict = await manager.is_conflict_active()
+
+ if is_silence or is_conflict:
+ output += "🔴 Активные режимы:\n\n"
+
+ if is_silence:
+ silence_until_str = await manager.repo.get_setting("silence_until")
+ silence_until = datetime.fromtimestamp(float(silence_until_str))
+ time_left_seconds = (silence_until - datetime.now()).total_seconds()
+ time_left_minutes = int(time_left_seconds / 60)
+
+ output += f"🔇 Режим тишины:\n"
+ output += f"├─ Статус: ✅ Активен\n"
+ output += f"├─ Осталось: {format_time_remaining(time_left_minutes)}\n"
+ output += f"├─ Окончание: {format_datetime(silence_until)}\n"
+ output += f"└─ Эффект: Удаляются ВСЕ сообщения\n\n"
+
+ if is_conflict:
+ conflict_until_str = await manager.repo.get_setting("conflict_until")
+ conflict_until = datetime.fromtimestamp(float(conflict_until_str))
+ time_left_seconds = (conflict_until - datetime.now()).total_seconds()
+ time_left_minutes = int(time_left_seconds / 60)
+
+ conflict_words_count = len(data.get('conflict_substring', set()))
+ conflict_lemmas_count = len(data.get('conflict_lemma', set()))
+
+ output += f"⚔️ Режим антиконфликта:\n"
+ output += f"├─ Статус: ✅ Активен\n"
+ output += f"├─ Осталось: {format_time_remaining(time_left_minutes)}\n"
+ output += f"├─ Окончание: {format_datetime(conflict_until)}\n"
+ output += f"├─ Слов: {conflict_words_count}\n"
+ output += f"├─ Лемм: {conflict_lemmas_count}\n"
+ output += f"└─ Эффект: Обычные банворды отключены\n\n"
+
+ # Детальная статистика правил
+ output += f"📋 Правила модерации:\n\n"
+
+ output += f"🔴 Постоянные:\n"
+ output += f"├─ Подстроки: {len(data.get('substring', set()))}\n"
+ output += f"├─ Леммы: {len(data.get('lemma', set()))}\n"
+ output += f"└─ Части: {len(data.get('part', set()))}\n\n"
+
+ output += f"⏱ Временные:\n"
+ output += f"├─ Подстроки: {len(data.get('temp_substring', set()))}\n"
+ output += f"└─ Леммы: {len(data.get('temp_lemma', set()))}\n\n"
+
+ output += f"⚔️ Конфликтные:\n"
+ output += f"├─ Слова: {len(data.get('conflict_substring', set()))}\n"
+ output += f"└─ Леммы: {len(data.get('conflict_lemma', set()))}\n\n"
+
+ output += f"✅ Исключения: {len(data.get('whitelist', set()))}\n\n"
+
+ # Информация о кэше
+ cache_info = stats.get('cache_active', False)
+ cache_updated = stats.get('cache_updated_at', None)
+
+ output += f"💾 Кэш:\n"
+ output += f"├─ Статус: {'✅ Активен' if cache_info else '❌ Неактивен'}\n"
+
+ if cache_updated and isinstance(cache_updated, str):
+ try:
+ updated_dt = datetime.fromisoformat(cache_updated)
+ output += f"└─ Обновлён: {format_datetime(updated_dt)}\n"
+ except (ValueError, TypeError):
+ output += f"└─ Обновлён: недавно\n"
+ else:
+ output += f"└─ Не обновлялся\n"
+
+ # Кнопка возврата
+ ikb = InlineKeyboardBuilder()
+ ikb.button(text="◀️ Назад", callback_data="stats:refresh")
+
+ await callback.message.edit_text(
+ text=output,
+ parse_mode="HTML",
+ reply_markup=ikb.as_markup()
+ )
+ await callback.answer()
+
+ except Exception as e:
+ logger.error(f"Ошибка получения детальной статистики: {e}", log_type="STATS")
+ await callback.answer("❌ Ошибка загрузки", show_alert=True)
+
+
+# ================= ТОП СПАМЕРОВ =================
+
+@router.callback_query(F.data == "stats:top_spammers")
+@log_action(action_name="VIEW_TOP_SPAMMERS")
+async def stats_top_spammers_callback(callback: CallbackQuery) -> None:
+ """Показывает топ-10 спамеров"""
+ manager = get_manager()
+
+ try:
+ top_spammers = await manager.get_top_spammers(limit=10)
+
+ output = "🏆 ТОП-10 СПАМЕРОВ\n\n"
+
+ if top_spammers:
+ max_count = top_spammers[0][1] if top_spammers else 1
+
+ for idx, (user_id, count) in enumerate(top_spammers, 1):
+ bar = create_text_bar(count, max_count, length=10)
+
+ # Эмодзи для топ-3
+ if idx == 1:
+ medal = "🥇"
+ elif idx == 2:
+ medal = "🥈"
+ elif idx == 3:
+ medal = "🥉"
+ else:
+ medal = f"{idx}."
+
+ output += f'{medal} {user_id}\n'
+ output += f" └─ {format_number(count)} удалений [{bar}]\n\n"
+
+ # Общая статистика
+ total_spammers = len(top_spammers)
+ total_deletions = sum(count for _, count in top_spammers)
+
+ output += f"📊 Статистика:\n"
+ output += f"├─ Всего пользователей: {total_spammers}\n"
+ output += f"└─ Всего удалений: {format_number(total_deletions)}\n\n"
+
+ output += "💡 ID можно использовать для проверки пользователя"
+ else:
+ output += "└─ Нет данных об удалениях\n\n"
+ output += "💡 Когда бот начнёт удалять сообщения, здесь появится статистика"
+
+ # Кнопка возврата
+ ikb = InlineKeyboardBuilder()
+ ikb.button(text="◀️ Назад", callback_data="stats:refresh")
+
+ await callback.message.edit_text(
+ text=output,
+ parse_mode="HTML",
+ reply_markup=ikb.as_markup()
+ )
+ await callback.answer()
+
+ except Exception as e:
+ logger.error(f"Ошибка получения топ спамеров: {e}", log_type="STATS")
+ await callback.answer("❌ Ошибка загрузки", show_alert=True)
+
+
+# ================= ТОП СЛОВ =================
+
+@router.callback_query(F.data == "stats:top_words")
+async def stats_top_words_callback(callback: CallbackQuery) -> None:
+ """Показывает топ-10 самых частых срабатываний"""
+ await callback.answer()
+
+ manager = get_manager()
+
+ # Получаем топ слов
+ top_words = await manager.get_top_words(limit=10)
+
+ if not top_words:
+ text = (
+ "🔤 ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ\n\n"
+ "📭 Статистика пока пуста\n\n"
+ "Срабатывания появятся после удаления\n"
+ "первых спам-сообщений."
+ )
+ else:
+ text = "🔤 ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ\n\n"
+
+ # Эмодзи для типов
+ type_emoji = {
+ "substring": "🔤",
+ "lemma": "📖",
+ "part": "🧩",
+ "silence": "🔇",
+ "conflict_substring": "⚔️",
+ "conflict_lemma": "⚔️"
+ }
+
+ for i, word_data in enumerate(top_words, 1):
+ word = word_data['word']
+ count = word_data['count']
+ word_type = word_data['type']
+ emoji = type_emoji.get(word_type, "❓")
+
+ # Медали для топ-3
+ medal = ""
+ if i == 1:
+ medal = "🥇 "
+ elif i == 2:
+ medal = "🥈 "
+ elif i == 3:
+ medal = "🥉 "
+
+ text += f"{medal}{i}. {emoji} {word} — {count} раз\n"
+
+ # Общая статистика
+ total = await manager.get_total_spam_count()
+ text += f"\n📊 Всего удалено: {total} сообщений"
+
+ # Кнопка назад
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="◀️ Назад", callback_data="stats:refresh")]
+ ])
+
+ try:
+ await callback.message.edit_text(
+ text=text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+ except Exception as e:
+ logger.error(f"Ошибка показа топ-слов: {e}", log_type="ERROR")
+ await callback.answer("❌ Ошибка загрузки статистики", show_alert=True)
+
+
+# ================= СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ =================
+
+@router.message(Command(*COMMANDS.get("userstats", ["userstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
+@log_action(action_name="VIEW_USER_STATS", log_args=True)
+async def user_stats_cmd(message: Message) -> None:
+ """
+ Показывает статистику конкретного пользователя.
+
+ Использование: /userstats
+ Пример: /userstats 123456789
+ """
+ parts = message.text.split(maxsplit=1)
+
+ if len(parts) < 2:
+ await message.answer(
+ "❌ Использование: /userstats [ID]\n\n"
+ "Пример: /userstats 123456789",
+ parse_mode="HTML"
+ )
+ return
+
+ try:
+ user_id = int(parts[1].strip())
+ except ValueError:
+ await message.answer("❌ ID должен быть числом", parse_mode="HTML")
+ return
+
+ manager = get_manager()
+
+ try:
+ # Получаем статистику пользователя
+ user_spam_count = await manager.get_user_spam_count(user_id)
+ user_spam_stats = await manager.get_spam_stats(limit=10, user_id=user_id)
+
+ output = f"👤 СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ\n\n"
+ output += f'🆔 ID: {user_id}\n\n'
+
+ if user_spam_count > 0:
+ output += f"🗑 Удалено сообщений: {format_number(user_spam_count)}\n\n"
+
+ if user_spam_stats:
+ output += f"📝 Последние удаления:\n"
+
+ for stat in user_spam_stats[:5]:
+ deleted_at = stat.deleted_at
+ matched_word = stat.matched_word or "неизвестно"
+ match_type = stat.match_type or "unknown"
+
+ output += f"├─ {format_datetime(deleted_at)}\n"
+ output += f"│ └─ Слово: {matched_word} ({match_type})\n"
+
+ output += "\n"
+ else:
+ output += "✅ Нет нарушений\n\n"
+ output += "Этот пользователь не нарушал правила чата"
+
+ await message.answer(output, parse_mode="HTML")
+
+ except Exception as e:
+ logger.error(f"Ошибка получения статистики пользователя: {e}", log_type="STATS")
+ await message.answer("❌ Ошибка загрузки статистики", parse_mode="HTML")
+
+
+# ================= СБРОС СТАТИСТИКИ =================
+
+@router.message(Command(*COMMANDS.get("resetstats", ["resetstats"]), prefix=settings.PREFIX, ignore_case=True),
+ IsAdmin())
+@log_action(action_name="RESET_STATS")
+async def reset_stats_cmd(message: Message) -> None:
+ """
+ Сбрасывает всю статистику удалений.
+
+ ⚠️ ВНИМАНИЕ: Это действие необратимо!
+
+ Использование: /resetstats confirm
+ """
+ parts = message.text.split(maxsplit=1)
+
+ if len(parts) < 2 or parts[1].lower() != "confirm":
+ await message.answer(
+ "⚠️ ВНИМАНИЕ!\n\n"
+ "Эта команда удалит ВСЮ статистику удалений:\n"
+ "• Счётчики удалений пользователей\n"
+ "• Историю удалённых сообщений\n"
+ "• Топ спамеров\n\n"
+ "Правила модерации НЕ будут удалены.\n\n"
+ "Для подтверждения используйте:\n"
+ "/resetstats confirm",
+ parse_mode="HTML"
+ )
+ return
+
+ manager = get_manager()
+
+ try:
+ # Сбрасываем статистику
+ deleted_count = await manager.reset_spam_stats()
+
+ if deleted_count > 0:
+ await message.answer(
+ f"✅ Статистика сброшена\n\n"
+ f"Удалено записей: {deleted_count}\n\n"
+ f"Новые данные начнут собираться\n"
+ f"с этого момента.",
+ parse_mode="HTML"
+ )
+ logger.warning(
+ f"Статистика сброшена пользователем {message.from_user.id}: "
+ f"удалено {deleted_count} записей",
+ log_type="STATS"
+ )
+ else:
+ await message.answer(
+ "ℹ️ Статистика уже пуста",
+ parse_mode="HTML"
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка сброса статистики: {e}", log_type="STATS")
+ await message.answer("❌ Ошибка сброса статистики", parse_mode="HTML")
+