Получение статистики по блокировкам

This commit is contained in:
2026-02-23 14:26:38 +07:00
parent a6de194d18
commit e5225067f7

View File

@@ -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 = "📊 <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()))
total_conflict = conflict_words_count + conflict_lemmas_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")