Files
PrimoGuardBot/bot/handlers/commands/users/stats.py

596 lines
24 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Обработчики команды статистики
"""
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 = "📊 <b>СТАТИСТИКА PRIMOGUARD</b>\n\n"
# Общая информация
total_deletions = stats.get('total_deletions', 0)
output += f"🗑 <b>Всего удалений:</b> <code>{format_number(total_deletions)}</code>\n\n"
# Активные режимы
if is_silence or is_conflict:
output += "🔴 <b>АКТИВНЫЕ РЕЖИМЫ:</b>\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"🔇 <b>Режим тишины</b>\n"
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\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()))
conflict_parts_count = len(data.get('conflict_part', set()))
total_conflict = (
conflict_words_count +
conflict_lemmas_count +
conflict_parts_count
)
output += f"⚔️ <b>Режим антиконфликта</b>\n"
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"├─ 🕐 До: {format_datetime(conflict_until)}\n"
output += f"└─ 📊 Правил: <code>{total_conflict}</code>\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"📋 <b>Правила модерации:</b>\n"
output += f"├─ Всего правил: <code>{total_rules}</code>\n"
output += f"├─ Постоянные: <code>{len(data.get('substring', set())) + len(data.get('lemma', set())) + len(data.get('part', set()))}</code>\n"
output += f"├─ Временные: <code>{len(data.get('temp_substring', set())) + len(data.get('temp_lemma', set()))}</code>\n"
output += f"├─ Конфликтные: <code>{len(data.get('conflict_substring', set())) + len(data.get('conflict_lemma', set()))}</code>\n"
output += f"└─ Исключения: <code>{len(data.get('whitelist', set()))}</code>\n\n"
# Топ-5 спамеров
if top_spammers:
output += "🏆 <b>Топ-5 спамеров:</b>\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}. <a href="tg://user?id={user_id}">{user_id}</a> — {count} [{bar}]\n'
output += "\n"
else:
output += "🏆 <b>Топ-5 спамеров:</b>\n"
output += "└─ <i>Нет данных</i>\n\n"
# Администраторы
admins_count = len(settings.OWNER_ID) + len(data.get('admins', set()))
output += f"👥 <b>Администраторов:</b> <code>{admins_count}</code>\n\n"
# Подсказка
output += "💡 <i>Используйте кнопки для детальной информации</i>"
# Клавиатура
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 = "❌ <b>Ошибка загрузки статистики</b>\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 = "📊 <b>ДЕТАЛЬНАЯ СТАТИСТИКА</b>\n\n"
# Подробная статистика удалений
total_deletions = stats.get('total_deletions', 0)
output += f"🗑 <b>Удаления сообщений:</b>\n"
output += f"├─ Всего: <code>{format_number(total_deletions)}</code>\n"
output += "\n"
# Активные режимы (детально)
is_silence = await manager.is_silence_active()
is_conflict = await manager.is_conflict_active()
if is_silence or is_conflict:
output += "🔴 <b>Активные режимы:</b>\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"🔇 <b>Режим тишины:</b>\n"
output += f"├─ Статус: ✅ Активен\n"
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\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"⚔️ <b>Режим антиконфликта:</b>\n"
output += f"├─ Статус: ✅ Активен\n"
output += f"├─ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
output += f"├─ Окончание: {format_datetime(conflict_until)}\n"
output += f"├─ Слов: <code>{conflict_words_count}</code>\n"
output += f"├─ Лемм: <code>{conflict_lemmas_count}</code>\n"
output += f"└─ Эффект: Обычные банворды отключены\n\n"
# Детальная статистика правил
output += f"📋 <b>Правила модерации:</b>\n\n"
output += f"🔴 <b>Постоянные:</b>\n"
output += f"├─ Подстроки: <code>{len(data.get('substring', set()))}</code>\n"
output += f"├─ Леммы: <code>{len(data.get('lemma', set()))}</code>\n"
output += f"└─ Части: <code>{len(data.get('part', set()))}</code>\n\n"
output += f"⏱ <b>Временные:</b>\n"
output += f"├─ Подстроки: <code>{len(data.get('temp_substring', set()))}</code>\n"
output += f"└─ Леммы: <code>{len(data.get('temp_lemma', set()))}</code>\n\n"
output += f"⚔️ <b>Конфликтные:</b>\n"
output += f"├─ Слова: <code>{len(data.get('conflict_substring', set()))}</code>\n"
output += f"└─ Леммы: <code>{len(data.get('conflict_lemma', set()))}</code>\n\n"
output += f"✅ <b>Исключения:</b> <code>{len(data.get('whitelist', set()))}</code>\n\n"
# Информация о кэше
cache_info = stats.get('cache_active', False)
cache_updated = stats.get('cache_updated_at', None)
output += f"💾 <b>Кэш:</b>\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 = "🏆 <b>ТОП-10 СПАМЕРОВ</b>\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} <a href="tg://user?id={user_id}">{user_id}</a>\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"📊 <b>Статистика:</b>\n"
output += f"├─ Всего пользователей: <code>{total_spammers}</code>\n"
output += f"└─ Всего удалений: <code>{format_number(total_deletions)}</code>\n\n"
output += "💡 <i>ID можно использовать для проверки пользователя</i>"
else:
output += "└─ <i>Нет данных об удалениях</i>\n\n"
output += "💡 <i>Когда бот начнёт удалять сообщения, здесь появится статистика</i>"
# Кнопка возврата
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 = (
"🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\n\n"
"📭 <i>Статистика пока пуста</i>\n\n"
"Срабатывания появятся после удаления\n"
"первых спам-сообщений."
)
else:
text = "🔤 <b>ТОП-10 СРАБАТЫВАНИЙ ПО СЛОВАМ</b>\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}<b>{i}.</b> {emoji} <code>{word}</code> — {count} раз\n"
# Общая статистика
total = await manager.get_total_spam_count()
text += f"\n📊 <b>Всего удалено:</b> {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 <ID>
Пример: /userstats 123456789
"""
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
await message.answer(
"❌ Использование: <code>/userstats [ID]</code>\n\n"
"Пример: <code>/userstats 123456789</code>",
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"👤 <b>СТАТИСТИКА ПОЛЬЗОВАТЕЛЯ</b>\n\n"
output += f'🆔 ID: <a href="tg://user?id={user_id}">{user_id}</a>\n\n'
if user_spam_count > 0:
output += f"🗑 <b>Удалено сообщений:</b> <code>{format_number(user_spam_count)}</code>\n\n"
if user_spam_stats:
output += f"📝 <b>Последние удаления:</b>\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"│ └─ Слово: <code>{matched_word}</code> ({match_type})\n"
output += "\n"
else:
output += "✅ <i>Нет нарушений</i>\n\n"
output += "Этот пользователь не нарушал правила чата"
await message.answer(output, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка получения статистики пользователя: {e}", log_type="STATS")
await message.answer("❌ <b>Ошибка загрузки статистики</b>", 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(
"⚠️ <b>ВНИМАНИЕ!</b>\n\n"
"Эта команда удалит ВСЮ статистику удалений:\n"
"• Счётчики удалений пользователей\n"
"• Историю удалённых сообщений\n"
"• Топ спамеров\n\n"
"Правила модерации НЕ будут удалены.\n\n"
"Для подтверждения используйте:\n"
"<code>/resetstats confirm</code>",
parse_mode="HTML"
)
return
manager = get_manager()
try:
# Сбрасываем статистику
deleted_count = await manager.reset_spam_stats()
if deleted_count > 0:
await message.answer(
f"✅ <b>Статистика сброшена</b>\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(
" <b>Статистика уже пуста</b>",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка сброса статистики: {e}", log_type="STATS")
await message.answer("❌ <b>Ошибка сброса статистики</b>", parse_mode="HTML")