diff --git a/bot/handlers/chl_comment.py b/bot/handlers/chl_comment.py index 397a471..69949b8 100644 --- a/bot/handlers/chl_comment.py +++ b/bot/handlers/chl_comment.py @@ -1,7 +1,9 @@ """ Автоматическая отправка комментариев под постами канала (через discussion group) + + меню настройки (FSM) + полная диагностика ++ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!) ВАЖНО: - Комментарии в Telegram — это reply в привязанной группе обсуждений. @@ -11,7 +13,7 @@ from __future__ import annotations import time -from typing import Optional, Tuple, Dict +from typing import Optional, Tuple, Dict, Any, List from aiogram import Router, F, Bot from aiogram.types import Message, CallbackQuery @@ -23,10 +25,11 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from configs import settings -from database import get_manager +from configs import settings, COMMANDS +from database import get_manager, AutoComment from middleware.loggers import logger from bot.filters.admin import IsAdmin +from bot.utils import log_action, tg_emoji __all__ = ("router",) @@ -43,7 +46,7 @@ class CommentEditStates(StatesGroup): waiting_button_text = State() waiting_button_url = State() waiting_photo_url = State() - + waiting_add_channel = State() # ✅ ДОБАВИЛИ # ====================================================================== # HELPERS @@ -58,25 +61,24 @@ def _defaults() -> dict: "is_enabled": False, } +def _render_menu_text(channel_id: int, config: dict) -> str: + status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" -async def get_channel_config(channel_id: int) -> dict: - """ - Получает настройки автокомментариев для канала из БД. - Ничего "не затирает": если поля отсутствуют — подставляет дефолты. - """ - manager = get_manager() - config = await manager.get_auto_comment_settings(channel_id) or {} - - merged = _defaults() - merged.update({k: v for k, v in config.items() if v is not None}) - - # Если в БД is_enabled=False, пользовательские поля (текст/кнопка/фото) сохраняем - # и просто считаем фичу выключенной. - if "is_enabled" not in config: - merged["is_enabled"] = False - - return merged + text = config.get("text") or "" + photo_url = config.get("photo_url") or "" + text_preview = (text[:100] + "...") if len(text) > 100 else text + photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url + return ( + f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n" + f"📢 Канал: {channel_id}\n" + f"🔘 Статус: {status_emoji}\n\n" + f"📝 Текст:\n{text_preview or '(пусто)'}\n\n" + f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n" + f"🔗 URL: {config.get('button_url') or ''}\n\n" + f"🖼 Фото:\n{photo_preview}\n\n" + f"💡 Выберите действие:" + ) def create_main_menu(channel_id: int) -> InlineKeyboardBuilder: """Создаёт главное меню управления автокомментариями""" @@ -87,57 +89,50 @@ def create_main_menu(channel_id: int) -> InlineKeyboardBuilder: ikb.button(text="👁 Предпросмотр", callback_data=f"edit:{channel_id}:preview") ikb.button(text="🔄 Переключить", callback_data=f"edit:{channel_id}:toggle") ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic") - ikb.button(text="🗑 Удалить настройки", callback_data=f"edit:{channel_id}:delete") + ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ + ikb.button(text="🗑 Удалить", callback_data=f"edit:{channel_id}:delete") ikb.button(text="❌ Закрыть", callback_data="menu:close") - ikb.adjust(2, 2, 2, 1, 1) + ikb.adjust(2, 2, 2, 2, 1) return ikb - -def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder: +def create_channels_menu(channels: List[int]) -> InlineKeyboardBuilder(): # ✅ List[int] """Создаёт меню выбора канала""" ikb = InlineKeyboardBuilder() for channel_id in channels: ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}") + ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ ikb.button(text="❌ Закрыть", callback_data="menu:close") ikb.adjust(1) return ikb +async def get_all_channels() -> List[int]: # ✅ ✅ ✅ ИСПРАВЛЕНО: async! + """Получает ВСЕ каналы из БД""" + manager = get_manager() + return await manager.get_auto_comment_channels() def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]: - full_text = hide_link(config["photo_url"]) + (config["text"] or "") + photo_url = (config.get("photo_url") or "").strip() + text = config.get("text") or "" + + full_text = (hide_link(photo_url) if photo_url else "") + text + keyboard = InlineKeyboardBuilder() if config.get("button_text") and config.get("button_url"): keyboard.button(text=config["button_text"], url=config["button_url"]) return full_text, keyboard - def _extract_origin_channel_id(message: Message) -> Optional[int]: - """ - Для auto-forward из привязанного канала Telegram обычно проставляет: - - message.is_automatic_forward = True - - message.forward_from_chat = канал - - Если forward_from_chat вдруг отсутствует — возвращаем None. - """ if not message.is_automatic_forward: return None - if message.forward_from_chat and message.forward_from_chat.type == "channel": return message.forward_from_chat.id - return None - # Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id) -_MEDIA_GROUP_SEEN: Dict[Tuple[int, str], float] = {} +_MEDIA_GROUP_SEEN: Dict[tuple[int, str], float] = {} _MEDIA_GROUP_TTL_SEC = 45.0 - def _media_group_should_skip(message: Message) -> bool: - """ - Возвращает True если это повторная часть альбома и мы уже комментировали. - Ключ: (chat_id, media_group_id). - """ if not message.media_group_id: return False @@ -145,7 +140,6 @@ def _media_group_should_skip(message: Message) -> bool: key = (message.chat.id, str(message.media_group_id)) last = _MEDIA_GROUP_SEEN.get(key) - # чистка старых ключей (лениво) if len(_MEDIA_GROUP_SEEN) > 500: cutoff = now - _MEDIA_GROUP_TTL_SEC for k, t in list(_MEDIA_GROUP_SEEN.items()): @@ -158,18 +152,72 @@ def _media_group_should_skip(message: Message) -> bool: _MEDIA_GROUP_SEEN[key] = now return False +async def get_channel_config(channel_id: int) -> dict: + """ + Получает настройки автокомментариев для канала из БД. + Ничего "не затирает": если поля отсутствуют — подставляет дефолты. + """ + manager = get_manager() + config = await manager.get_auto_comment_settings(channel_id) or {} + + merged = _defaults() + merged.update({k: v for k, v in config.items() if v is not None}) + + if "is_enabled" not in config: + merged["is_enabled"] = False + + return merged + +async def _persist_settings_preserve_enabled( + channel_id: int, + patch: dict, + updated_by: int, +) -> bool: + """ + Надёжное сохранение настроек: + - всегда делает "первичное сохранение" через save_auto_comment_settings (чтобы запись точно появилась) + - сохраняет старый is_enabled (если было выключено — выключаем обратно после сохранения) + """ + manager = get_manager() + + raw = await manager.get_auto_comment_settings(channel_id) or {} + was_enabled = bool(raw.get("is_enabled", False)) + + merged = _defaults() + merged.update({k: v for k, v in raw.items() if v is not None}) + merged.update({k: v for k, v in patch.items() if v is not None}) + + # save_auto_comment_settings у тебя уже используется при включении (значит умеет создавать запись) + success = await manager.save_auto_comment_settings( + channel_id=channel_id, + text=merged.get("text") or "", + button_text=merged.get("button_text") or "", + button_url=merged.get("button_url") or "", + photo_url=merged.get("photo_url") or "", + updated_by=updated_by, + ) + if not success: + return False + + # Если было выключено — сохраняем выключенным (на случай если save_* включает фичу) + if not was_enabled: + try: + await manager.repo.toggle_auto_comment( + channel_id=channel_id, + is_enabled=False, + updated_by=updated_by, + ) + except Exception as e: + logger.warning(f"toggle_auto_comment failed (preserve disabled): {e}", log_type="CHANNEL") + + return True # ====================================================================== -# CORE: AUTO COMMENTS (discussion group) +# CORE: AUTO COMMENTS (discussion group) ✅ ФИКС #3 # ====================================================================== @router.message(F.is_automatic_forward) async def auto_comment_from_discussion_forward(message: Message) -> None: - """ - Ловим пост канала, автоматически пересланный в привязанную группу обсуждений. - Комментарий отправляем reply на это сообщение => появляется "под постом". - """ - # 0) Дедуп альбомов if _media_group_should_skip(message): logger.debug( f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}", @@ -183,7 +231,6 @@ async def auto_comment_from_discussion_forward(message: Message) -> None: log_type="CHANNEL" ) - # 1) Канал-источник channel_id = _extract_origin_channel_id(message) if not channel_id: logger.warning( @@ -192,23 +239,17 @@ async def auto_comment_from_discussion_forward(message: Message) -> None: ) return - # 2) Проверка списка каналов - channels = settings.AUTO_COMMENT_CHANNELS_LIST + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await! if not channels: - logger.warning("❌ AUTO_COMMENT_CHANNELS_LIST пуст — нечего обрабатывать", log_type="CHANNEL") return - if channel_id not in channels: - logger.debug(f"⏭ Channel {channel_id} not in configured list", log_type="CHANNEL") return - # 3) /test_comment (если админ запостил команду в канале — она тоже прилетит сюда автофорвардом) is_test = False txt = message.text or message.caption or "" if "/test_comment" in txt: is_test = True - # 4) Настройки и статус try: config = await get_channel_config(channel_id) except Exception as e: @@ -219,22 +260,21 @@ async def auto_comment_from_discussion_forward(message: Message) -> None: logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL") return - # 5) Формируем и отправляем комментарий (reply в группе) try: full_text, keyboard = _build_comment_payload(config) sent = await message.reply( text=full_text, reply_markup=keyboard.as_markup(), - parse_mode="HTML" + parse_mode="HTML", ) logger.success( - "✅ Comment sent (discussion reply)\n" + f"✅ Comment sent (discussion reply)\n" f" ├─ Origin channel: {channel_id}\n" f" ├─ Discussion chat: {message.chat.id}\n" f" ├─ Forward msg id: {message.message_id}\n" - f" └─ Comment msg id: {sent.message_id}\n" + f" ├─ Comment msg id: {sent.message_id}\n" f" └─ Test mode: {is_test}", log_type="CHANNEL" ) @@ -253,19 +293,60 @@ async def auto_comment_from_discussion_forward(message: Message) -> None: log_type="CHANNEL" ) except Exception as e: - logger.error( - f"❌ Unexpected error while sending comment: {e}", - log_type="CHANNEL", - ) - + logger.error(f"❌ Unexpected error while sending comment: {e}", log_type="CHANNEL") # ====================================================================== -# DIAGNOSTICS +# ✅ НОВЫЕ ХЕНДЛЕРЫ ДЛЯ ДОБАВЛЕНИЯ КАНАЛА +# ====================================================================== + +@router.callback_query(F.data == "add_channel", IsAdmin()) +async def add_channel_callback(callback: CallbackQuery, state: FSMContext) -> None: + await state.update_data(action="add_channel") + await state.set_state(CommentEditStates.waiting_add_channel) + + await callback.message.edit_text( + text=( + "➕ ДОБАВИТЬ КАНАЛ\n\n" + "Отправьте ID канала (число с минусом):\n" + "Пример: -1003876862007\n\n" + "💡 @userinfobot для получения ID\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) + await callback.answer() + +@router.message(CommentEditStates.waiting_add_channel, IsAdmin()) +async def process_add_channel(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + try: + channel_id = int(message.text.strip()) + if not str(channel_id).startswith('-'): + raise ValueError() + except ValueError: + await message.answer("❌ Неверный ID. Пример: -1003876862007", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.add_auto_comment_channel(channel_id, message.from_user.id) + + await state.clear() + + if success: + await message.answer(f"✅ Канал добавлен!\n{channel_id}\n/redactcomment", parse_mode="HTML") + else: + await message.answer(f"❌ Канал {channel_id} уже существует!", parse_mode="HTML") + +# ====================================================================== +# DIAGNOSTICS ✅ ФИКС #2 # ====================================================================== @router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic")) async def diagnostic_channel_callback(callback: CallbackQuery) -> None: - """Запускает полную диагностику канала""" channel_id = int(callback.data.split(":")[1]) bot: Bot = callback.bot @@ -273,13 +354,11 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: diagnostic_text = "🔍 ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ\n\n" - # 1) ENV settings - channels = settings.AUTO_COMMENT_CHANNELS_LIST + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await! diagnostic_text += "1️⃣ Настройки:\n" - diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: {channels}\n" + diagnostic_text += f" ├─ Каналы из БД: {channels}\n" diagnostic_text += f" └─ Канал в списке: {'✅' if channel_id in channels else '❌'}\n\n" - # 2) DB config diagnostic_text += "2️⃣ База данных:\n" try: config = await get_channel_config(channel_id) @@ -292,7 +371,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" config = None - # 3) Bot status in channel diagnostic_text += "3️⃣ Бот в канале:\n" try: member = await bot.get_chat_member(channel_id, bot.id) @@ -313,7 +391,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: except Exception as e: diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" - # 4) Linked discussion group diagnostic_text += "4️⃣ Привязанная группа обсуждений:\n" linked_chat_id = None try: @@ -327,7 +404,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: except Exception as e: diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" - # 5) Bot status in discussion group diagnostic_text += "5️⃣ Бот в группе обсуждений:\n" if not linked_chat_id: diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n" @@ -340,7 +416,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: else: diagnostic_text += " ├─ Присутствует: ❌\n" - # can_send_messages бывает не у всех типов, поэтому hasattr if hasattr(gmember, "can_send_messages"): diagnostic_text += f" └─ can_send_messages: {'✅' if gmember.can_send_messages else '❌'}\n\n" else: @@ -350,33 +425,37 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None: except Exception as e: diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" - # Recommendations diagnostic_text += "💡 Что должно быть для работы:\n" if channel_id not in channels: - diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n" - diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n" - diagnostic_text += " • Подключите discussion group к каналу\n" - diagnostic_text += " • Дайте боту право писать в группе обсуждений\n" - diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n" - - await callback.message.answer(text=diagnostic_text, parse_mode="HTML") + diagnostic_text += " • Добавьте канал ➕\n" + diagnostic_text += ( + " • Включите автокомментарии (🔄 Переключить)\n" + " • Подключите discussion group к каналу\n" + " • Дайте боту право писать в группе обсуждений\n" + " • Для теста: отправьте пост в канал или пост с /test_comment\n" + ) + if callback.message: + await callback.message.answer(text=diagnostic_text, parse_mode="HTML") # ====================================================================== -# ADMIN UI: COMMAND + MENUS +# ADMIN UI: COMMAND + MENUS ✅ ФИКС #1 # ====================================================================== -@router.message(Command("redactcomment"), IsAdmin()) +@router.callback_query(F.data.casefold() == "redactcomment", IsAdmin()) +@router.message(Command(*COMMANDS["redactcomment"], prefix=settings.PREFIX, ignore_case=True), IsAdmin()) +@log_action(action_name="START_COMMAND", log_args=True) async def redact_comment_cmd(message: Message, state: FSMContext) -> None: - """Открывает меню управления автокомментариями""" - channels = settings.AUTO_COMMENT_CHANNELS_LIST + channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await! + + await state.clear() if not channels: await message.answer( - "❌ Каналы не настроены\n\n" - "Добавьте ID каналов в .env файл:\n" - "AUTO_COMMENT_CHANNELS=-1003876862007\n\n" - "💡 Узнать ID канала: перешлите пост из канала боту @userinfobot", + "📢 УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ\n\n" + "🚫 Каналы не настроены\n\n" + "👆 ➕ Добавить канал", + reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка parse_mode="HTML" ) return @@ -385,33 +464,14 @@ async def redact_comment_cmd(message: Message, state: FSMContext) -> None: await show_channel_menu(message, channels[0]) else: await message.answer( - "📢 УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ\n\n" - "Выберите канал для настройки:", + "📢 Выберите канал:", reply_markup=create_channels_menu(channels).as_markup(), parse_mode="HTML" ) - async def show_channel_menu(message: Message, channel_id: int) -> None: - """Показывает меню настроек для конкретного канала""" config = await get_channel_config(channel_id) - status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" - - text = config.get("text") or "" - photo_url = config.get("photo_url") or "" - text_preview = (text[:100] + "...") if len(text) > 100 else text - photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url - - output = ( - f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n" - f"📢 Канал: {channel_id}\n" - f"🔘 Статус: {status_emoji}\n\n" - f"📝 Текст:\n{text_preview or '(пусто)'}\n\n" - f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n" - f"🔗 URL: {config.get('button_url') or ''}\n\n" - f"🖼 Фото:\n{photo_preview}\n\n" - f"💡 Выберите действие:" - ) + output = _render_menu_text(channel_id, config) await message.answer( text=output, @@ -419,77 +479,70 @@ async def show_channel_menu(message: Message, channel_id: int) -> None: parse_mode="HTML" ) - @router.callback_query(F.data.startswith("select_channel:")) -async def select_channel_callback(callback: CallbackQuery) -> None: - """Обработка выбора канала из списка""" +async def select_channel_callback(callback: CallbackQuery, state: FSMContext) -> None: channel_id = int(callback.data.split(":")[1]) + await state.clear() + config = await get_channel_config(channel_id) - status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" + output = _render_menu_text(channel_id, config) - text = config.get("text") or "" - photo_url = config.get("photo_url") or "" - text_preview = (text[:100] + "...") if len(text) > 100 else text - photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url - - output = ( - f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n" - f"📢 Канал: {channel_id}\n" - f"🔘 Статус: {status_emoji}\n\n" - f"📝 Текст:\n{text_preview or '(пусто)'}\n\n" - f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n" - f"🔗 URL: {config.get('button_url') or ''}\n\n" - f"🖼 Фото:\n{photo_preview}\n\n" - f"💡 Выберите действие:" - ) - - await callback.message.edit_text( - text=output, - reply_markup=create_main_menu(channel_id).as_markup(), - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) await callback.answer() - # ====================================================================== # EDIT TEXT # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):text")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"), IsAdmin()) async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None: channel_id = int(callback.data.split(":")[1]) await state.update_data(channel_id=channel_id) await state.set_state(CommentEditStates.waiting_text) - await callback.message.edit_text( - text=( - "📝 РЕДАКТИРОВАНИЕ ТЕКСТА\n\n" - "Отправьте новый текст комментария.\n\n" - "💡 Поддерживается HTML\n\n" - "Для отмены: /cancel" - ), - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text( + text=( + "📝 РЕДАКТИРОВАНИЕ ТЕКСТА\n\n" + "Отправьте новый текст комментария.\n\n" + "💡 Поддерживается HTML\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) await callback.answer() - -@router.message(CommentEditStates.waiting_text) +@router.message(CommentEditStates.waiting_text, IsAdmin()) async def process_text_input(message: Message, state: FSMContext) -> None: - if message.text == "/cancel": + if (message.text or "").strip() == "/cancel": await state.clear() await message.answer("❌ Отменено") return data = await state.get_data() channel_id = data.get("channel_id") + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return - manager = get_manager() - success = await manager.update_auto_comment_text( - channel_id=channel_id, - text=message.text or "", - updated_by=message.from_user.id - ) + new_text = message.text or "" + + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"text": new_text}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update text failed: {e}", log_type="CHANNEL") + success = False await state.clear() @@ -500,35 +553,34 @@ async def process_text_input(message: Message, state: FSMContext) -> None: ) return - await message.answer(f"✅ Текст обновлён!", parse_mode="HTML") - await show_channel_menu(message, channel_id) - + await message.answer("✅ Текст обновлён!", parse_mode="HTML") + await show_channel_menu(message, int(channel_id)) # ====================================================================== # EDIT BUTTON # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):button")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):button"), IsAdmin()) async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None: channel_id = int(callback.data.split(":")[1]) await state.update_data(channel_id=channel_id) await state.set_state(CommentEditStates.waiting_button_text) - await callback.message.edit_text( - text=( - "🔘 РЕДАКТИРОВАНИЕ КНОПКИ\n\n" - "Шаг 1 из 2: Отправьте текст кнопки\n\n" - "Для отмены: /cancel" - ), - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text( + text=( + "🔘 РЕДАКТИРОВАНИЕ КНОПКИ\n\n" + "Шаг 1 из 2: Отправьте текст кнопки\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) await callback.answer() - -@router.message(CommentEditStates.waiting_button_text) +@router.message(CommentEditStates.waiting_button_text, IsAdmin()) async def process_button_text(message: Message, state: FSMContext) -> None: - if message.text == "/cancel": + if (message.text or "").strip() == "/cancel": await state.clear() await message.answer("❌ Отменено") return @@ -538,22 +590,21 @@ async def process_button_text(message: Message, state: FSMContext) -> None: await message.answer( text=( - f"✅ Текст кнопки: {message.text}\n\n" + f"✅ Текст кнопки: {(message.text or '').strip()}\n\n" f"Шаг 2 из 2: Отправьте URL кнопки\n\n" f"Для отмены: /cancel" ), parse_mode="HTML" ) - -@router.message(CommentEditStates.waiting_button_url) +@router.message(CommentEditStates.waiting_button_url, IsAdmin()) async def process_button_url(message: Message, state: FSMContext) -> None: - if message.text == "/cancel": + if (message.text or "").strip() == "/cancel": await state.clear() await message.answer("❌ Отменено") return - url = message.text or "" + url = (message.text or "").strip() if not url.startswith(("http://", "https://")): await message.answer( "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", @@ -563,56 +614,62 @@ async def process_button_url(message: Message, state: FSMContext) -> None: data = await state.get_data() channel_id = data.get("channel_id") - button_text = data.get("button_text") or "" + button_text = (data.get("button_text") or "").strip() - manager = get_manager() - success = await manager.update_auto_comment_button( - channel_id=channel_id, - button_text=button_text, - button_url=url, - updated_by=message.from_user.id - ) + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return + + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"button_text": button_text, "button_url": url}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update button failed: {e}", log_type="CHANNEL") + success = False await state.clear() if not success: - await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML") return await message.answer("✅ Кнопка обновлена!", parse_mode="HTML") - await show_channel_menu(message, channel_id) - + await show_channel_menu(message, int(channel_id)) # ====================================================================== # EDIT PHOTO URL # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"), IsAdmin()) async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None: channel_id = int(callback.data.split(":")[1]) await state.update_data(channel_id=channel_id) await state.set_state(CommentEditStates.waiting_photo_url) - await callback.message.edit_text( - text=( - "🖼 РЕДАКТИРОВАНИЕ ФОТО\n\n" - "Отправьте прямую ссылку на изображение (http/https).\n\n" - "Для отмены: /cancel" - ), - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text( + text=( + "🖼 РЕДАКТИРОВАНИЕ ФОТО\n\n" + "Отправьте прямую ссылку на изображение (http/https).\n\n" + "Для отмены: /cancel" + ), + parse_mode="HTML" + ) await callback.answer() - -@router.message(CommentEditStates.waiting_photo_url) +@router.message(CommentEditStates.waiting_photo_url, IsAdmin()) async def process_photo_url(message: Message, state: FSMContext) -> None: - if message.text == "/cancel": + if (message.text or "").strip() == "/cancel": await state.clear() await message.answer("❌ Отменено") return - url = message.text or "" + url = (message.text or "").strip() if not url.startswith(("http://", "https://")): await message.answer( "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", @@ -622,48 +679,54 @@ async def process_photo_url(message: Message, state: FSMContext) -> None: data = await state.get_data() channel_id = data.get("channel_id") + if not channel_id: + await state.clear() + await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment") + return - manager = get_manager() - success = await manager.update_auto_comment_photo( - channel_id=channel_id, - photo_url=url, - updated_by=message.from_user.id - ) + try: + success = await _persist_settings_preserve_enabled( + channel_id=int(channel_id), + patch={"photo_url": url}, + updated_by=message.from_user.id + ) + except Exception as e: + logger.error(f"update photo failed: {e}", log_type="CHANNEL") + success = False await state.clear() if not success: - await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML") return await message.answer(hide_link(url) + "✅ Фото обновлено!", parse_mode="HTML") - await show_channel_menu(message, channel_id) - + await show_channel_menu(message, int(channel_id)) # ====================================================================== # PREVIEW # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview"), IsAdmin()) async def preview_comment_callback(callback: CallbackQuery) -> None: channel_id = int(callback.data.split(":")[1]) config = await get_channel_config(channel_id) full_text, keyboard = _build_comment_payload(config) - await callback.message.answer( - text=f"👁 ПРЕВЬЮ КОММЕНТАРИЯ\n\n{full_text}", - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) + if callback.message: + await callback.message.answer( + text=f"👁 ПРЕВЬЮ КОММЕНТАРИЯ\n\n{full_text}", + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) await callback.answer("✅ Превью отправлено") - # ====================================================================== # TOGGLE # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"), IsAdmin()) async def toggle_comment_callback(callback: CallbackQuery) -> None: channel_id = int(callback.data.split(":")[1]) @@ -695,37 +758,21 @@ async def toggle_comment_callback(callback: CallbackQuery) -> None: await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True) - # Обновляем меню config = await get_channel_config(channel_id) - status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" - text = config.get("text") or "" - photo_url = config.get("photo_url") or "" - text_preview = (text[:100] + "...") if len(text) > 100 else text - photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url - - output = ( - f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n" - f"📢 Канал: {channel_id}\n" - f"🔘 Статус: {status_emoji}\n\n" - f"📝 Текст:\n{text_preview or '(пусто)'}\n\n" - f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n" - f"🔗 URL: {config.get('button_url') or ''}\n\n" - f"🖼 Фото:\n{photo_preview}\n\n" - f"💡 Выберите действие:" - ) - - await callback.message.edit_text( - text=output, - reply_markup=create_main_menu(channel_id).as_markup(), - parse_mode="HTML" - ) + output = _render_menu_text(channel_id, config) + if callback.message: + await callback.message.edit_text( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) # ====================================================================== # DELETE SETTINGS # ====================================================================== -@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete")) +@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"), IsAdmin()) async def delete_comment_callback(callback: CallbackQuery) -> None: channel_id = int(callback.data.split(":")[1]) @@ -737,34 +784,16 @@ async def delete_comment_callback(callback: CallbackQuery) -> None: return await callback.answer("🗑 Настройки удалены", show_alert=True) - await callback.message.edit_text( - text=( - "🗑 НАСТРОЙКИ УДАЛЕНЫ\n\n" - f"Автокомментарии для канала {channel_id} удалены.\n\n" - "Будут использоваться настройки по умолчанию из .env\n\n" - "Для настройки: /redactcomment" - ), - parse_mode="HTML" - ) + + if callback.message: + await callback.message.edit_text( + text=( + "🗑 НАСТРОЙКИ УДАЛЕНЫ\n\n" + f"Автокомментарии для канала {channel_id} удалены.\n\n" + "Будут использоваться настройки по умолчанию из .env\n\n" + "Для настройки: /redactcomment" + ), + parse_mode="HTML" + ) -# ====================================================================== -# CLOSE / CANCEL -# ====================================================================== - -@router.callback_query(F.data == "menu:close") -async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None: - await state.clear() - await callback.message.delete() - await callback.answer("❌ Меню закрыто") - - -@router.message(Command("cancel")) -async def cancel_handler(message: Message, state: FSMContext) -> None: - current_state = await state.get_state() - if current_state is None: - await message.answer("❌ Нечего отменять") - return - - await state.clear() - await message.answer("✅ Действие отменено") diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py index d3fab41..a6cbd95 100644 --- a/bot/handlers/commands/users/__init__.py +++ b/bot/handlers/commands/users/__init__.py @@ -11,6 +11,8 @@ from .admins import router as admin_router from .notifications import router as notifications_router from .id import router as id_router from .emoji import router as emoji_router +from .cancel import router as cancel_router +from .bot_settings import router as setting_router # Настройка экспорта и роутера __all__ = ("router",) @@ -19,6 +21,7 @@ router: Router = Router(name=__name__) # Подключение роутеров router.include_routers( +cancel_router, notifications_router, report_router, admin_router, @@ -30,4 +33,5 @@ conflict_router, stats_router, id_router, emoji_router, +setting_router, ) diff --git a/bot/handlers/commands/users/admins.py b/bot/handlers/commands/users/admins.py index 46ffe95..3c2f320 100644 --- a/bot/handlers/commands/users/admins.py +++ b/bot/handlers/commands/users/admins.py @@ -6,248 +6,197 @@ from aiogram.filters import Command from aiogram.types import Message, CallbackQuery from aiogram.utils.keyboard import InlineKeyboardBuilder +from bot import bot # ← ДОБАВЬ ЭТОТ ИМПОРТ from bot.filters.admin import IsSuperAdmin from configs import settings, COMMANDS from database import get_manager from middleware.loggers import logger -from bot.utils.decorators import log_action +from bot.utils import log_action, tg_emoji __all__ = ("router",) router: Router = Router(name="admin_management_router") -# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= - def parse_user_id(text: str, command: str) -> tuple[bool, str | int]: - """ - Парсит ID пользователя из команды. - - Args: - text: Полный текст сообщения - command: Название команды - - Returns: - (success, result): result это либо user_id (int), либо текст ошибки (str) - """ parts = text.split(maxsplit=1) - if len(parts) < 2: - return False, f"❌ Использование: /{command} " + return False, f'{tg_emoji("4961187972822074653")} Использование: /{command} ' user_id_str = parts[1].strip() - - # Валидация ID try: user_id = int(user_id_str) - if user_id <= 0: - return False, "❌ ID должен быть положительным числом" - - if user_id > 9999999999: # Максимальный Telegram ID - return False, "❌ Некорректный ID пользователя" - + return False, f'{tg_emoji("4961187972822074653")} ID должен быть положительным числом' + if user_id > 9999999999: + return False, f'{tg_emoji("4961187972822074653")} Некорректный ID пользователя' return True, user_id - except ValueError: - return False, "❌ ID должен быть числом" + return False, f'{tg_emoji("4961187972822074653")} ID должен быть числом' + + +async def get_user_display_name(user_id: int) -> str: + """Получает имя пользователя или username или ID""" + try: + chat = await bot.get_chat(user_id) + name = f"{chat.first_name or ''} {chat.last_name or ''}".strip() + if name: + return name + if chat.username: + return f"@{chat.username}" + return str(user_id) + except: + return str(user_id) def format_admin_info(user_id: int, username: str | None = None) -> str: - """Форматирует информацию об админе""" if username: - return f"{user_id} (@{username})" - return f"{user_id}" + return f'{user_id} (@{username})' + return f'{user_id}' def get_refresh_admins_kb(): - """Клавиатура для обновления списка админов""" ikb = InlineKeyboardBuilder() - ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh") - ikb.button(text="➕ Добавить", callback_data="admin:help_add") + ikb.button(text='🔄 Обновить', callback_data='listadmins:refresh') + ikb.button(text='➕ Добавить', callback_data='admin:help_add') ikb.adjust(2) return ikb.as_markup() # ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА ================= -@router.message(Command(*COMMANDS.get("addadmin", ["addadmin"]), prefix=settings.PREFIX, ignore_case=True), +@router.message(Command(*COMMANDS.get('addadmin', ['addadmin']), prefix=settings.PREFIX, ignore_case=True), IsSuperAdmin()) -@log_action(action_name="ADD_ADMIN", log_args=True) +@log_action(action_name='ADD_ADMIN', log_args=True) async def add_admin_cmd(message: Message) -> None: - """ - Добавляет нового администратора бота. - - Доступно только владельцам бота (OWNER_ID). - - Использование: /addadmin - Пример: /addadmin 123456789 - """ - success, result = parse_user_id(message.text, "addadmin") - + success, result = parse_user_id(message.text, 'addadmin') if not success: - await message.answer(result, parse_mode="HTML") + await message.answer(result, parse_mode='HTML') return user_id = result - # Проверка: нельзя добавить самого себя if user_id == message.from_user.id: await message.answer( - "⚠️ Вы уже владелец бота\n\n" - "Вам не нужно добавлять себя в администраторы", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Вы уже владелец бота\n\n' + 'Вам не нужно добавлять себя в администраторы', + parse_mode='HTML' ) return - # Проверка: нельзя добавить другого владельца if user_id in settings.OWNER_ID: await message.answer( - "⚠️ Этот пользователь уже владелец бота\n\n" - "Владельцы имеют полные права автоматически", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Этот пользователь уже владелец бота\n\n' + 'Владельцы имеют полные права автоматически', + parse_mode='HTML' ) return manager = get_manager() - try: - # Проверяем, уже админ ли is_already_admin = await manager.is_admin(user_id) - if is_already_admin: + display_name = await get_user_display_name(user_id) await message.answer( - f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Пользователь {display_name} уже является администратором', + parse_mode='HTML' ) return - # Добавляем администратора - added = await manager.add_admin( - user_id=user_id, - added_by=message.from_user.id - ) - + added = await manager.add_admin(user_id=user_id, added_by=message.from_user.id) if added: + display_name = await get_user_display_name(user_id) text = ( - f"✅ Администратор добавлен\n\n" - f"👤 ID: {format_admin_info(user_id)}\n" - f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n" - f"📋 Права администратора:\n" - f"├─ Управление банвордами\n" - f"├─ Просмотр статистики\n" - f"├─ Активация режимов модерации\n" - f"└─ Все команды бота\n\n" - f"⚠️ Не может управлять другими админами\n" - f"Список админов: /listadmins" - ) - - logger.info( - f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})", - log_type="ADMIN_MGMT" + f'{tg_emoji("4963010134172239128")} Администратор добавлен\n\n' + f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n' + f'{tg_emoji("4963343509533754468")} Имя: {display_name}\n' + f'{tg_emoji("4963343509533754468")} Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n' + f'{tg_emoji("4961106084975608869")} Права администратора:\n' + f'├─ Управление банвордами\n' + f'├─ Просмотр статистики\n' + f'├─ Активация режимов модерации\n' + f'└─ Все команды бота\n\n' + f'{tg_emoji("4963024861615096794")} Не может управлять другими админами\n' + f'Список админов: /listadmins' ) + logger.info(f'Администратор добавлен: {user_id} (добавил: {message.from_user.id})', log_type='ADMIN_MGMT') else: - text = "❌ Ошибка добавления администратора\n\nПопробуйте позже" + text = f'{tg_emoji("4961187972822074653")} Ошибка добавления администратора\n\nПопробуйте позже' - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') except Exception as e: - logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT") - await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") + logger.error(f'Ошибка добавления администратора: {e}', log_type='ADMIN_MGMT') + await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка добавления\n\nПопробуйте позже', parse_mode='HTML') # ================= УДАЛЕНИЕ АДМИНИСТРАТОРА ================= -@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True), +@router.message(Command(*COMMANDS.get('remadmin', ['remadmin']), prefix=settings.PREFIX, ignore_case=True), IsSuperAdmin()) -@log_action(action_name="REMOVE_ADMIN", log_args=True) +@log_action(action_name='REMOVE_ADMIN', log_args=True) async def remove_admin_cmd(message: Message) -> None: - """ - Удаляет администратора бота. - - Доступно только владельцам бота (OWNER_ID). - - Использование: /remadmin - Пример: /remadmin 123456789 - """ - success, result = parse_user_id(message.text, "remadmin") - + success, result = parse_user_id(message.text, 'remadmin') if not success: - await message.answer(result, parse_mode="HTML") + await message.answer(result, parse_mode='HTML') return user_id = result - # Проверка: нельзя удалить владельца if user_id in settings.OWNER_ID: await message.answer( - "⚠️ Нельзя удалить владельца\n\n" - "Владельцы имеют права постоянно", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Нельзя удалить владельца\n\n' + 'Владельцы имеют права постоянно', + parse_mode='HTML' ) return - # Проверка: нельзя удалить самого себя (если вы владелец) if user_id == message.from_user.id: await message.answer( - "⚠️ Нельзя удалить самого себя", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Нельзя удалить самого себя', + parse_mode='HTML' ) return manager = get_manager() - try: - # Проверяем, является ли администратором is_admin = await manager.is_admin(user_id) - if not is_admin: + display_name = await get_user_display_name(user_id) await message.answer( - f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором", - parse_mode="HTML" + f'{tg_emoji("4963024861615096794")} Пользователь {display_name} не является администратором', + parse_mode='HTML' ) return - # Удаляем администратора removed = await manager.remove_admin(user_id=user_id) - if removed: + display_name = await get_user_display_name(user_id) text = ( - f"🗑 Администратор удалён\n\n" - f"👤 ID: {format_admin_info(user_id)}\n" - f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n" - f"⚠️ Пользователь больше не имеет доступа к командам бота" - ) - - logger.info( - f"Администратор удалён: {user_id} (удалил: {message.from_user.id})", - log_type="ADMIN_MGMT" + f'🗑 Администратор удалён\n\n' + f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n' + f'{tg_emoji("4961064956368782417")} Имя: {display_name}\n' + f'{tg_emoji("4963343509533754468")} Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n' + f'{tg_emoji("4963024861615096794")} Пользователь больше не имеет доступа к командам бота' ) + logger.info(f'Администратор удалён: {user_id} (удалил: {message.from_user.id})', log_type='ADMIN_MGMT') else: - text = "❌ Ошибка удаления администратора\n\nПопробуйте позже" + text = f'{tg_emoji("4961187972822074653")} Ошибка удаления администратора\n\nПопробуйте позже' - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') except Exception as e: - logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT") - await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") + logger.error(f'Ошибка удаления администратора: {e}', log_type='ADMIN_MGMT') + await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка удаления\n\nПопробуйте позже', parse_mode='HTML') # ================= СПИСОК АДМИНИСТРАТОРОВ ================= -@router.callback_query(F.data == "listadmins:refresh") -@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True), +@router.callback_query(F.data == 'listadmins:refresh') +@router.message(Command(*COMMANDS.get('listadmins', ['listadmins']), prefix=settings.PREFIX, ignore_case=True), IsSuperAdmin()) -@log_action(action_name="LIST_ADMINS") +@log_action(action_name='LIST_ADMINS') async def list_admins_cmd(update: Message | CallbackQuery) -> None: - """ - Показывает список всех администраторов бота. - - Доступно только владельцам бота (OWNER_ID). - - Использование: /listadmins - """ - # Определяем тип update if isinstance(update, CallbackQuery): message = update.message is_callback = True @@ -256,179 +205,143 @@ async def list_admins_cmd(update: Message | CallbackQuery) -> None: is_callback = False manager = get_manager() - try: - # Получаем всех админов из БД db_admins = await manager.repo.get_admins() - # Получаем статистику - stats = await manager.get_stats() + output = f'{tg_emoji("4960891456869893259")} СПИСОК АДМИНИСТРАТОРОВ\n\n' - # === ФОРМИРУЕМ ВЫВОД === - - output = "👥 СПИСОК АДМИНИСТРАТОРОВ\n\n" - - # Владельцы (OWNER_ID) - output += "👑 Владельцы бота (полные права):\n" + # ВЛАДЕЛЬЦЫ + output += f'{tg_emoji("4963343509533754468")} Владельцы бота (полные права):\n' for owner_id in settings.OWNER_ID: - output += f'├─ {owner_id}\n' - output += "\n" + display_name = await get_user_display_name(owner_id) + output += f'├─ {display_name}\n' + output += '\n' - # Администраторы из БД + # АДМИНИСТРАТОРЫ if db_admins: - output += f"⚙️ Администраторы ({len(db_admins)}):\n" - + output += f'{tg_emoji("4961064956368782417")} Администраторы ({len(db_admins)}):\n' for admin_id in sorted(db_admins): - output += f'├─ {admin_id}\n' - - output += "\n" - output += "📋 Права администраторов:\n" - output += "├─ Управление банвордами\n" - output += "├─ Просмотр статистики\n" - output += "├─ Активация режимов модерации\n" - output += "└─ Все команды бота (кроме управления админами)\n\n" + display_name = await get_user_display_name(admin_id) + output += f'├─ {display_name}\n' + output += '\n' + output += f'{tg_emoji("4961106084975608869")} Права администраторов:\n' + output += '├─ Управление банвордами\n' + output += '├─ Просмотр статистики\n' + output += '├─ Активация режимов модерации\n' + output += '└─ Все команды бота\n\n' else: - output += "⚙️ Администраторы:\n" - output += "└─ Нет дополнительных администраторов\n\n" + output += f'{tg_emoji("4961064956368782417")} Администраторы:\n' + output += '└─ Нет дополнительных администраторов\n\n' - # Общая статистика total_admins = len(settings.OWNER_ID) + len(db_admins) - output += f"📊 Итого: {total_admins} администратор(ов)\n\n" + output += f'{tg_emoji("4961061266991875258")} Итого: {total_admins} администратор(ов)\n\n' - # Команды управления - output += "🔧 Управление:\n" - output += "• /addadmin ID — добавить админа\n" - output += "• /remadmin ID — удалить админа\n\n" + output += f'{tg_emoji("4961027057577362562")} Управление:\n' + output += '• /adminhelp — помощь по командам админов\n' + output += '• /addadmin ID — добавить админа\n' + output += '• /remadmin ID — удалить админа\n\n' + output += f'{tg_emoji("4961186405159011104")} Только владельцы могут управлять администраторами' - output += "💡 Только владельцы могут управлять администраторами" - - # Клавиатура keyboard = get_refresh_admins_kb() - # Отправка if is_callback: - await message.edit_text( - text=output, - parse_mode="HTML", - reply_markup=keyboard - ) - await update.answer("✅ Список обновлён") + await message.edit_text(text=output, parse_mode='HTML', reply_markup=keyboard) + await update.answer(f'{tg_emoji("4963010134172239128")} Список обновлён') else: - await message.answer( - text=output, - parse_mode="HTML", - reply_markup=keyboard - ) + await message.answer(text=output, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT") - - error_text = "❌ Ошибка загрузки списка\n\nПопробуйте позже" - + logger.error(f'Ошибка получения списка администраторов: {e}', log_type='ADMIN_MGMT') + error_text = f'{tg_emoji("4961187972822074653")} Ошибка загрузки списка\n\nПопробуйте позже' if is_callback: - await update.answer("❌ Ошибка загрузки", show_alert=True) + await update.answer(f'{tg_emoji("4961187972822074653")} Ошибка загрузки', show_alert=True) else: - await message.answer(error_text, parse_mode="HTML") + await message.answer(error_text, parse_mode='HTML') # ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK ================= -@router.callback_query(F.data == "admin:help_add") +@router.callback_query(F.data == 'admin:help_add') async def admin_help_add_callback(callback: CallbackQuery) -> None: - """Показывает помощь по добавлению админа""" text = ( - "➕ Как добавить администратора?\n\n" - "1️⃣ Узнайте Telegram ID пользователя\n" - " • Используйте бота @userinfobot\n" - " • Или попросите пользователя написать /start\n\n" - "2️⃣ Выполните команду:\n" - " /addadmin ID\n\n" - "Пример:\n" - "/addadmin 123456789" + f'{tg_emoji("4963469772982322370")} Как добавить администратора?\n\n' + f'{tg_emoji("4960889107522782272")} Узнайте Telegram ID пользователя\n' + ' • Используйте команду /id или бота @userinfobot\n' + f'{tg_emoji("4960889107522782272")} Выполните команду:\n' + ' /addadmin ID\n\n' + 'Пример:\n' + '/addadmin 123456789' ) - await callback.answer() - await callback.message.answer(text, parse_mode="HTML") + await callback.message.answer(text, parse_mode='HTML') -@router.message(Command(*COMMANDS.get("adminhelp", ["adminhelp"]), prefix=settings.PREFIX, ignore_case=True), +@router.message(Command(*COMMANDS.get('adminhelp', ['adminhelp']), prefix=settings.PREFIX, ignore_case=True), IsSuperAdmin()) async def admin_help_cmd(message: Message) -> None: - """ - Показывает подробную справку по управлению администраторами. - - Использование: /adminhelp - """ text = ( - "👥 УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ\n\n" - "🔐 Уровни доступа:\n\n" - "👑 Владельцы (OWNER_ID):\n" - "├─ Все права администратора\n" - "├─ Управление другими админами\n" - "└─ Указываются в конфигурации\n\n" - "⚙️ Администраторы:\n" - "├─ Управление банвордами\n" - "├─ Просмотр статистики\n" - "├─ Активация режимов модерации\n" - "└─ НЕ могут управлять админами\n\n" - "📝 Команды:\n" - "• /listadmins — список всех админов\n" - "• /addadmin ID — добавить админа\n" - "• /remadmin ID — удалить админа\n\n" - "💡 Как узнать ID пользователя?\n" - "• Используйте бота @userinfobot\n" - "• Попросите пользователя написать боту\n" - "• ID отображается в логах бота\n\n" - "⚠️ Важно:\n" - "├─ Нельзя удалить владельца\n" - "├─ Нельзя удалить самого себя\n" - "└─ Все действия логируются" + f'{tg_emoji("4960891456869893259")} УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ\n\n' + f'{tg_emoji("4963401727815451692")} Уровни доступа:\n\n' + f'{tg_emoji("4963343509533754468")} Владельцы (OWNER_ID):\n' + '├─ Все права администратора\n' + '├─ Управление другими админами\n' + '└─ Указываются в конфигурации\n\n' + f'{tg_emoji("4961064956368782417")} Администраторы:\n' + '├─ Управление банвордами\n' + '├─ Просмотр статистики\n' + '├─ Активация режимов модерации\n' + '└─ Не могут управлять админами\n\n' + f'{tg_emoji("4963241130398319816")} Команды:\n' + '• /adminhelp — помощь по командам админов\n' + '• /listadmins — список всех админов\n' + '• /addadmin ID — добавить админа\n' + '• /remadmin ID — удалить админа\n\n' + f'{tg_emoji("4961186405159011104")} Как узнать ID пользователя?\n' + '• Используйте команду /id или бота @userinfobot\n' + '• Или попросите пользователя написать боту\n' + '• ID отображается в логах бота\n\n' + f'{tg_emoji("4963024861615096794")} Важно:\n' + '├─ Нельзя удалить владельца\n' + '├─ Нельзя удалить самого себя\n' + '└─ Все действия логируются' ) - - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') -@router.message(Command(*COMMANDS.get("checkadmin", ["checkadmin"]), prefix=settings.PREFIX, ignore_case=True), +@router.message(Command(*COMMANDS.get('checkadmin', ['checkadmin']), prefix=settings.PREFIX, ignore_case=True), IsSuperAdmin()) -@log_action(action_name="CHECK_ADMIN") +@log_action(action_name='CHECK_ADMIN') async def check_admin_cmd(message: Message) -> None: - """ - Проверяет, является ли пользователь администратором. - - Использование: /checkadmin - """ - success, result = parse_user_id(message.text, "checkadmin") - + success, result = parse_user_id(message.text, 'checkadmin') if not success: - await message.answer(result, parse_mode="HTML") + await message.answer(result, parse_mode='HTML') return user_id = result manager = get_manager() try: - # Проверяем статус is_owner = user_id in settings.OWNER_ID is_db_admin = await manager.is_admin(user_id) - text = f"🔍 Проверка пользователя\n\n" - text += f"👤 ID: {user_id}\n\n" + text = f'{tg_emoji("4961092195051373778")} Проверка пользователя\n\n' + text += f'{tg_emoji("4961064956368782417")} ID: {user_id}\n\n' if is_owner: - text += "👑 Статус: Владелец бота\n" - text += "✅ Полные права администратора\n" - text += "✅ Может управлять админами" + text += f'{tg_emoji("4963343509533754468")} Статус: Владелец бота\n' + text += f'{tg_emoji("4963010134172239128")} Полные права администратора\n' + text += f'{tg_emoji("4963010134172239128")} Может управлять админами' elif is_db_admin: - text += "⚙️ Статус: Администратор\n" - text += "✅ Доступ к командам бота\n" - text += "❌ Не может управлять админами" + text += f'{tg_emoji("4961064956368782417")} Статус: Администратор\n' + text += f'{tg_emoji("4963010134172239128")} Доступ к командам бота\n' + text += f'{tg_emoji("4961187972822074653")} Не может управлять админами' else: - text += "👤 Статус: Обычный пользователь\n" - text += "❌ Нет прав администратора\n\n" - text += f"Добавить в админы: /addadmin {user_id}" + text += f'{tg_emoji("4961064956368782417")} Статус: Обычный пользователь\n' + text += f'{tg_emoji("4961187972822074653")} Нет прав администратора\n\n' + text += f'Добавить в админы: /addadmin {user_id}' - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') except Exception as e: - logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT") - await message.answer("❌ Ошибка проверки", parse_mode="HTML") + logger.error(f'Ошибка проверки администратора: {e}', log_type='ADMIN_MGMT') + await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка проверки', parse_mode='HTML') diff --git a/bot/handlers/commands/users/bot_settings.py b/bot/handlers/commands/users/bot_settings.py new file mode 100644 index 0000000..23edb71 --- /dev/null +++ b/bot/handlers/commands/users/bot_settings.py @@ -0,0 +1,330 @@ +""" +Команда /settings - управление настройками БЕЗ .env +ADMIN_CHAT_ID, ADMIN_THREAD_ID, REPORT_CHAT_ID, REPORT_THREAD_ID +""" + +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery +from aiogram.filters import Command +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.exceptions import TelegramBadRequest + +from middleware.loggers import logger +from bot.filters.admin import IsAdmin +from database import get_manager + +__all__ = ("router",) + +router: Router = Router(name="bot_settings_router") + +# ====================================================================== +# FSM STATES +# ====================================================================== + +class BotSettingsStates(StatesGroup): + """Состояния для редактирования настроек бота""" + waiting_admin_chat = State() + waiting_admin_thread = State() + waiting_report_chat = State() + waiting_report_thread = State() + +# ====================================================================== +# MAIN MENU +# ====================================================================== + +def _format_chat_id(chat_id: str | None) -> str: + """Форматирует ID чата для отображения""" + if chat_id is None: + return "❌ Не установлен" + return f"✅ {chat_id}" + +def create_settings_menu() -> InlineKeyboardBuilder: + """Главное меню настроек""" + ikb = InlineKeyboardBuilder() + ikb.button(text="📢 Админ-чат", callback_data="settings:admin_chat") + ikb.button(text="🧵 Топик админ-чата", callback_data="settings:admin_thread") + ikb.button(text="📊 Чат репортов", callback_data="settings:report_chat") + ikb.button(text="🧵 Топик репортов", callback_data="settings:report_thread") + ikb.button(text="🔄 Обновить", callback_data="settings:refresh") + ikb.button(text="❌ Закрыть", callback_data="settings:close") + ikb.adjust(2) + return ikb + +def cancel_keyboard(): + """Клавиатура с кнопкой 'Назад' для окон ввода""" + ikb = InlineKeyboardBuilder() + ikb.button(text="◀️ Назад", callback_data="settings:cancel") + return ikb.as_markup() + +# ====================================================================== +# MAIN HANDLER +# ====================================================================== + +@router.message(Command("settings"), IsAdmin()) +async def settings_cmd(message: Message, state: FSMContext) -> None: + """Главная команда /settings""" + await state.clear() + await show_settings_menu(message) + +async def show_settings_menu(message_or_callback: Message | CallbackQuery) -> None: + """Показывает меню настроек (отправляет новое сообщение или редактирует существующее)""" + manager = get_manager() + current = await manager.get_bot_settings() + + text = ( + "⚙️ НАСТРОЙКИ БОТА\n\n" + "📢 Админ-чат: " + _format_chat_id(current.get('admin_chat_id')) + "\n" + "🧵 Топик админ: " + _format_chat_id(current.get('admin_thread_id')) + "\n\n" + "📊 Чат репортов: " + _format_chat_id(current.get('report_chat_id')) + "\n" + "🧵 Топик репортов: " + _format_chat_id(current.get('report_thread_id')) + "\n\n" + "💡 Используйте @userinfobot для получения ID чатов\n" + "💡 Для топиков: ID из сообщения в топике" + ) + + markup = create_settings_menu().as_markup() + + if isinstance(message_or_callback, Message): + await message_or_callback.answer(text, reply_markup=markup, parse_mode="HTML") + else: + try: + await message_or_callback.message.edit_text(text, reply_markup=markup, parse_mode="HTML") + except TelegramBadRequest as e: + if "message is not modified" in str(e): + await message_or_callback.answer("🔄 Нет изменений") + else: + raise + +# ====================================================================== +# CALLBACK HANDLERS +# ====================================================================== + +@router.callback_query(F.data == "settings:refresh") +async def refresh_settings(callback: CallbackQuery, state: FSMContext) -> None: + """Обновляет меню (с защитой от MessageNotModified)""" + await show_settings_menu(callback) + +@router.callback_query(F.data == "settings:close") +async def close_settings(callback: CallbackQuery, state: FSMContext) -> None: + """Закрывает меню""" + await state.clear() + try: + await callback.message.delete() + except: + pass + await callback.answer("❌ Закрыто") + +@router.callback_query(F.data == "settings:cancel") +async def cancel_edit(callback: CallbackQuery, state: FSMContext) -> None: + """Возврат в главное меню без сохранения""" + await state.clear() + await show_settings_menu(callback) + +@router.callback_query(F.data == "settings:admin_chat") +async def edit_admin_chat(callback: CallbackQuery, state: FSMContext) -> None: + """Редактирование админ-чата""" + await state.set_state(BotSettingsStates.waiting_admin_chat) + await callback.message.edit_text( + "📢 АДМИН-ЧАТ\n\n" + "Отправьте ID чата для уведомлений:\n" + "Пример: -1003764219200\n\n" + "Для отключения: null\n\n" + "Или нажмите кнопку ниже для возврата в меню.", + parse_mode="HTML", + reply_markup=cancel_keyboard() + ) + await callback.answer() + +@router.callback_query(F.data == "settings:admin_thread") +async def edit_admin_thread(callback: CallbackQuery, state: FSMContext) -> None: + """Редактирование топика админ-чата""" + await state.set_state(BotSettingsStates.waiting_admin_thread) + await callback.message.edit_text( + "🧵 ТОПИК АДМИН-ЧАТА\n\n" + "Отправьте ID топика:\n" + "Пример: 1\n\n" + "Для отключения: null\n\n" + "Или нажмите кнопку ниже для возврата в меню.", + parse_mode="HTML", + reply_markup=cancel_keyboard() + ) + await callback.answer() + +@router.callback_query(F.data == "settings:report_chat") +async def edit_report_chat(callback: CallbackQuery, state: FSMContext) -> None: + """Редактирование чата репортов""" + await state.set_state(BotSettingsStates.waiting_report_chat) + await callback.message.edit_text( + "📊 ЧАТ РЕПОРТОВ\n\n" + "Отправьте ID чата для репортов:\n" + "Пример: -1003764219200\n\n" + "Для отключения: null\n\n" + "Или нажмите кнопку ниже для возврата в меню.", + parse_mode="HTML", + reply_markup=cancel_keyboard() + ) + await callback.answer() + +@router.callback_query(F.data == "settings:report_thread") +async def edit_report_thread(callback: CallbackQuery, state: FSMContext) -> None: + """Редактирование топика репортов""" + await state.set_state(BotSettingsStates.waiting_report_thread) + await callback.message.edit_text( + "🧵 ТОПИК РЕПОРТОВ\n\n" + "Отправьте ID топика:\n" + "Пример: 1\n\n" + "Для отключения: null\n\n" + "Или нажмите кнопку ниже для возврата в меню.", + parse_mode="HTML", + reply_markup=cancel_keyboard() + ) + await callback.answer() + +# ====================================================================== +# MESSAGE HANDLERS (FSM) +# ====================================================================== + +@router.message(BotSettingsStates.waiting_admin_chat, IsAdmin()) +async def process_admin_chat(message: Message, state: FSMContext) -> None: + text = message.text.strip() + + if text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + if text == "null": + value = None + else: + try: + value = int(text) + if not str(value).startswith('-'): + raise ValueError("ID чата должен начинаться с минуса") + except ValueError: + await message.answer("❌ Неверный формат. Пример: -1003764219200", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.set_bot_setting("admin_chat_id", str(value) if value else None) + + await state.clear() + + if success: + # Показываем обновлённое главное меню + await show_settings_menu(message) + # Удаляем сообщение с вводом + try: + await message.delete() + except: + pass + else: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + +@router.message(BotSettingsStates.waiting_admin_thread, IsAdmin()) +async def process_admin_thread(message: Message, state: FSMContext) -> None: + text = message.text.strip() + + if text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + if text == "null": + value = None + else: + try: + value = int(text) + if value < 1: + raise ValueError("ID топика должен быть > 0") + except ValueError: + await message.answer("❌ Неверный формат. Пример: 1", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.set_bot_setting("admin_thread_id", str(value) if value else None) + + await state.clear() + + if success: + await show_settings_menu(message) + try: + await message.delete() + except: + pass + else: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + +@router.message(BotSettingsStates.waiting_report_chat, IsAdmin()) +async def process_report_chat(message: Message, state: FSMContext) -> None: + text = message.text.strip() + + if text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + if text == "null": + value = None + else: + try: + value = int(text) + if not str(value).startswith('-'): + raise ValueError("ID чата должен начинаться с минуса") + except ValueError: + await message.answer("❌ Неверный формат. Пример: -1003764219200", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.set_bot_setting("report_chat_id", str(value) if value else None) + + await state.clear() + + if success: + await show_settings_menu(message) + try: + await message.delete() + except: + pass + else: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + +@router.message(BotSettingsStates.waiting_report_thread, IsAdmin()) +async def process_report_thread(message: Message, state: FSMContext) -> None: + text = message.text.strip() + + if text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + if text == "null": + value = None + else: + try: + value = int(text) + if value < 1: + raise ValueError("ID топика должен быть > 0") + except ValueError: + await message.answer("❌ Неверный формат. Пример: 1", parse_mode="HTML") + return + + manager = get_manager() + success = await manager.set_bot_setting("report_thread_id", str(value) if value else None) + + await state.clear() + + if success: + await show_settings_menu(message) + try: + await message.delete() + except: + pass + else: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + +@router.message(Command("cancel")) +async def cancel_settings(message: Message, state: FSMContext) -> None: + """Глобальный cancel""" + await state.clear() + await message.answer("✅ Настройки отменены") diff --git a/bot/handlers/commands/users/cancel.py b/bot/handlers/commands/users/cancel.py new file mode 100644 index 0000000..bd9d059 --- /dev/null +++ b/bot/handlers/commands/users/cancel.py @@ -0,0 +1,48 @@ +# ====================================================================== +# CLOSE / CANCEL +# ====================================================================== +from __future__ import annotations + +from typing import Optional, Tuple, Dict, Any, List + +from aiogram import Router, F, Bot +from aiogram.types import Message, CallbackQuery +from aiogram.filters import Command +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.markdown import hide_link +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup + +from configs import settings, COMMANDS +from database import get_manager, AutoComment +from middleware.loggers import logger +from bot.filters.admin import IsAdmin +from bot.utils import log_action, tg_emoji + +__all__ = ("router",) +CMD: str = "cancel" +router: Router = Router(name="channel_comments_router") + +@router.callback_query(F.data == "menu:close") +async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + try: + if callback.message: + await callback.message.delete() + except TelegramBadRequest: + pass + await callback.answer("❌ Меню закрыто") + +@router.callback_query(F.data.casefold() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin()) +@log_action(action_name="START_COMMAND", log_args=True) +async def cancel_handler(message: Message, state: FSMContext) -> None: + current_state = await state.get_state() + if current_state is None: + await message.answer("❌ Нечего отменять") + return + + await state.clear() + await message.answer("✅ Действие отменено") diff --git a/bot/handlers/commands/users/emoji.py b/bot/handlers/commands/users/emoji.py index 807a908..4744966 100644 --- a/bot/handlers/commands/users/emoji.py +++ b/bot/handlers/commands/users/emoji.py @@ -14,23 +14,28 @@ __all__ = ("router",) router: Router = Router(name="emoji_extractor_router") +MAX_MSG_LEN = 3800 # Безопасный лимит (4096 - запас) +SEPARATOR = "\n" + "─" * 30 + "\n\n" + # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= +def _utf16_slice(text: str, offset: int, length: int) -> str: + """ + Корректно извлекает подстроку с учётом UTF-16 смещений Telegram API. + Telegram передаёт offset/length в UTF-16 code units, а не в Unicode codepoints. + """ + encoded = text.encode("utf-16-le") + return encoded[offset * 2 : (offset + length) * 2].decode("utf-16-le") + + def extract_custom_emojis(message: Message) -> list[dict]: """ - Извлекает все кастомные эмодзи из сообщения. - - Args: - message: Сообщение для анализа + Извлекает все кастомные эмодзи из сообщения (текст + подпись). Returns: - Список словарей с информацией об эмодзи + Список словарей: {"char": str, "id": str, "offset": int} """ - if not message.entities and not message.caption_entities: - return [] - - # Определяем текст и entities text = message.text or message.caption entities = message.entities or message.caption_entities @@ -38,44 +43,76 @@ def extract_custom_emojis(message: Message) -> list[dict]: return [] custom_emojis = [] - for entity in entities: if entity.type == "custom_emoji": - # Извлекаем символ эмодзи - emoji_char = text[entity.offset:entity.offset + entity.length] - + emoji_char = _utf16_slice(text, entity.offset, entity.length) custom_emojis.append({ "char": emoji_char, "id": entity.custom_emoji_id, - "offset": entity.offset + "offset": entity.offset, }) return custom_emojis def format_emoji_html(emoji_char: str, emoji_id: str) -> str: - """ - Форматирует эмодзи в HTML-тег. - - Args: - emoji_char: Символ эмодзи (fallback) - emoji_id: ID кастомного эмодзи - - Returns: - HTML-строка - """ return f'{emoji_char}' def escape_html(text: str) -> str: - """Экранирует HTML символы""" return ( text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") + .replace("<", "<") + .replace(">", ">") ) +def _build_emoji_block(idx: int, emoji_data: dict, is_last: bool) -> str: + """Формирует текстовый блок для одного эмодзи.""" + emoji_char = emoji_data["char"] + emoji_id = emoji_data["id"] + html_code = format_emoji_html(emoji_char, emoji_id) + html_escaped = escape_html(html_code) + + block = ( + f"{idx}. Эмодзи: {emoji_char}\n" + f"📋 ID: {emoji_id}\n\n" + f"📝 HTML-код:\n" + f"{html_escaped}\n\n" + f"🎨 Превью: {html_code}\n" + ) + + if not is_last: + block += SEPARATOR + + return block + + +def build_pages(custom_emojis: list[dict]) -> list[str]: + """ + Разбивает список эмодзи на страницы, каждая не длиннее MAX_MSG_LEN. + Возвращает список готовых HTML-строк для отправки. + """ + total = len(custom_emojis) + pages: list[str] = [] + current_page = "" + + for idx, emoji_data in enumerate(custom_emojis, 1): + is_last = (idx == total) + block = _build_emoji_block(idx, emoji_data, is_last) + + if current_page and len(current_page) + len(block) > MAX_MSG_LEN: + pages.append(current_page) + current_page = block + else: + current_page += block + + if current_page: + pages.append(current_page) + + return pages + + # ================= КОМАНДА /EMOJI ================= @router.message( @@ -83,14 +120,6 @@ def escape_html(text: str) -> str: IsAdmin() ) async def emoji_extractor_cmd(message: Message) -> None: - """ - Извлекает кастомные эмодзи из сообщения. - - Доступно только администраторам. - - Использование: /emoji (в ответ на сообщение) - """ - # Проверяем, что команда в ответ на сообщение if not message.reply_to_message: await message.answer( "❌ Используйте команду в ответ на сообщение\n\n" @@ -98,66 +127,57 @@ async def emoji_extractor_cmd(message: Message) -> None: "1. Ответьте на сообщение с премиум эмодзи\n" "2. Напишите /emoji\n\n" "💡 Бот извлечёт все кастомные эмодзи и покажет HTML-код", - parse_mode="HTML" + parse_mode="HTML", ) return replied_message = message.reply_to_message - - # Извлекаем кастомные эмодзи custom_emojis = extract_custom_emojis(replied_message) if not custom_emojis: - # Нет кастомных эмодзи await message.answer( "⚠️ Кастомные эмодзи не найдены\n\n" "В этом сообщении нет премиум эмодзи.\n\n" "💡 Попробуйте ответить на сообщение с анимированными эмодзи", - parse_mode="HTML" + parse_mode="HTML", ) return - # === ФОРМИРУЕМ ОТВЕТ === + total = len(custom_emojis) + pages = build_pages(custom_emojis) + total_pages = len(pages) - output = f"✨ НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}\n\n" - - for idx, emoji_data in enumerate(custom_emojis, 1): - emoji_char = emoji_data["char"] - emoji_id = emoji_data["id"] - - output += f"{idx}. Эмодзи: {emoji_char}\n" - output += f"📋 ID: {emoji_id}\n\n" - - # HTML-код (экранированный для отображения) - html_code = format_emoji_html(emoji_char, emoji_id) - html_escaped = escape_html(html_code) - - output += f"📝 HTML-код:\n" - output += f"{html_escaped}\n\n" - - # Пример использования - output += f"🎨 Превью: {html_code}\n" - - if idx < len(custom_emojis): - output += "\n" + "─" * 30 + "\n\n" - - output += "💡 Скопируйте HTML-код и используйте в своих сообщениях" - - # Создаём клавиатуру ikb = InlineKeyboardBuilder() ikb.button(text="✖️ Закрыть", callback_data="emoji_close") - # Отправляем try: - await message.answer( - text=output, - parse_mode="HTML", - reply_markup=ikb.as_markup() - ) + for page_num, page_content in enumerate(pages, 1): + # Заголовок только на первой странице + if page_num == 1: + header = f"✨ НАЙДЕНО ЭМОДЗИ: {total}\n\n" + else: + header = f"✨ ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})\n\n" + + # Подвал только на последней странице + footer = ( + "\n\n💡 Скопируйте HTML-код и используйте в своих сообщениях" + if page_num == total_pages + else "" + ) + + # Кнопка закрытия только на последней странице + markup = ikb.as_markup() if page_num == total_pages else None + + await message.answer( + text=header + page_content + footer, + parse_mode="HTML", + reply_markup=markup, + ) logger.info( - f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}", - log_type="EMOJI_EXTRACT" + f"Извлечено {total} кастомных эмодзи ({total_pages} стр.) " + f"админом {message.from_user.id}", + log_type="EMOJI_EXTRACT", ) except Exception as e: @@ -165,7 +185,7 @@ async def emoji_extractor_cmd(message: Message) -> None: await message.answer( "❌ Ошибка извлечения эмодзи\n\n" "Попробуйте позже или обратитесь к разработчику.", - parse_mode="HTML" + parse_mode="HTML", ) @@ -173,7 +193,6 @@ async def emoji_extractor_cmd(message: Message) -> None: @router.callback_query(lambda c: c.data == "emoji_close", IsAdmin()) async def emoji_close_callback(callback) -> None: - """Закрывает сообщение с эмодзи""" try: await callback.message.delete() await callback.answer("✅ Закрыто") @@ -189,9 +208,6 @@ async def emoji_close_callback(callback) -> None: IsAdmin() ) async def emoji_help_cmd(message: Message) -> None: - """ - Справка по работе с кастомными эмодзи. - """ text = ( "🎨 РАБОТА С КАСТОМНЫМИ ЭМОДЗИ\n\n" "📝 Команда /emoji\n" @@ -201,15 +217,11 @@ async def emoji_help_cmd(message: Message) -> None: "2️⃣ Напишите /emoji\n" "3️⃣ Скопируйте HTML-код\n\n" "💻 Формат HTML-кода:\n" - "<tg-emoji emoji-id=\"ID\">fallback</tg-emoji>\n\n" - "📌 Пример использования в коде:\n" - "text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\n" - "await message.answer(text, parse_mode=\"HTML\")\n\n" + '<tg-emoji emoji-id="ID">fallback</tg-emoji>\n\n' "⚠️ Важно:\n" - "├─ Используйте parse_mode=\"HTML\"\n" + '├─ Используйте parse_mode="HTML"\n' "├─ Пользователи без Premium видят fallback\n" "└─ Работает только с кастомными эмодзи\n\n" "💡 Попробуйте отправить эмодзи и ответить командой /emoji" ) - await message.answer(text, parse_mode="HTML") diff --git a/bot/handlers/commands/users/id.py b/bot/handlers/commands/users/id.py index a23fe07..caa3a1a 100644 --- a/bot/handlers/commands/users/id.py +++ b/bot/handlers/commands/users/id.py @@ -9,9 +9,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from configs import settings, COMMANDS from middleware.loggers import logger -__all__ = ("router",) +__all__ = ('router',) -router: Router = Router(name="user_id_router") +router: Router = Router(name='user_id_router') # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= @@ -19,13 +19,13 @@ router: Router = Router(name="user_id_router") def get_close_keyboard(): """Создаёт клавиатуру с кнопкой закрытия""" ikb = InlineKeyboardBuilder() - ikb.button(text="✖️ Закрыть", callback_data="id_close") + ikb.button(text='✖️ Закрыть', callback_data='id_close') return ikb.as_markup() # ================= КОМАНДА /ID ================= -@router.message(Command(*COMMANDS.get("id", ["id"]), prefix=settings.PREFIX, ignore_case=True)) +@router.message(Command(*COMMANDS.get('id', ['id']), prefix=settings.PREFIX, ignore_case=True)) async def id_cmd(message: Message) -> None: """ Показывает информацию о вашем Telegram аккаунте. @@ -37,12 +37,12 @@ async def id_cmd(message: Message) -> None: user = message.from_user if not user: - await message.answer("❌ Не удалось получить информацию о пользователе") + await message.answer('❌ Не удалось получить информацию о пользователе') return # === ФОРМИРУЕМ ИНФОРМАЦИЮ === - output = "👤 ИНФОРМАЦИЯ О ВАС\n\n" + output = '💠 ИНФОРМАЦИЯ О ВАС\n\n' # Имя full_name_parts = [] @@ -51,28 +51,28 @@ async def id_cmd(message: Message) -> None: if user.last_name: full_name_parts.append(user.last_name) - full_name = " ".join(full_name_parts) if full_name_parts else "Не указано" - output += f"📝 Имя: {full_name}\n" + full_name = ' '.join(full_name_parts) if full_name_parts else 'Не указано' + output += f'💠 Имя: {full_name}\n' # Username if user.username: - output += f"🔗 Username: @{user.username}\n" + output += f'💠 Username: @{user.username}\n' else: - output += f"🔗 Username: не установлен\n" + output += '💠 Username: не установлен\n' # ID - output += f"🆔 ID: {user.id}\n\n" + output += f'💠 ID: {user.id}\n\n' # Тип аккаунта if user.is_bot: - output += f"🤖 Тип: Бот\n" - elif user.is_premium: - output += f"⭐️ Тип: Premium пользователь\n" + output += '🤖 Тип: Бот\n' + elif getattr(user, 'is_premium', False): + output += '💠 Тип: Premium пользователь\n' else: - output += f"👥 Тип: Обычный пользователь\n" + output += '👥 Тип: Обычный пользователь\n' # Дополнительная информация - output += "\n📊 Дополнительно:\n" + output += '\n💠 Дополнительно:\n' # Язык if user.language_code: @@ -86,36 +86,33 @@ async def id_cmd(message: Message) -> None: 'it': '🇮🇹 Italiano', 'pt': '🇵🇹 Português', } - language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}") - output += f"├─ Язык: {language}\n" + language = language_names.get(user.language_code, f'🌐 {user.language_code.upper()}') + output += f'├─ Язык: {language}\n' # Информация о чате - if message.chat.type == "private": - output += f"├─ Чат: 💬 Личные сообщения\n" + if message.chat.type == 'private': + output += '├─ Чат: 💬 Личные сообщения\n' else: - chat_title = message.chat.title or "Без названия" + chat_title = message.chat.title or 'Без названия' chat_types = { - "group": "👥 Группа", - "supergroup": "👥 Супергруппа", - "channel": "📢 Канал" + 'group': '👥 Группа', + 'supergroup': '👥 Супергруппа', + 'channel': '📢 Канал' } - chat_type = chat_types.get(message.chat.type, "💬 Чат") - output += f"├─ Чат: {chat_type}\n" - output += f"├─ Название: {chat_title}\n" - output += f"├─ Chat ID: {message.chat.id}\n" + chat_type = chat_types.get(message.chat.type, '💬 Чат') + output += f'├─ Чат: {chat_type}\n' + output += f'├─ Название: {chat_title}\n' + output += f'├─ Chat ID: {message.chat.id}\n' # Получаем количество участников (только для групп) try: member_count = await message.bot.get_chat_member_count(message.chat.id) - output += f"├─ Участников: {member_count}\n" + output += f'├─ Участников: {member_count}\n' except Exception as e: - logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID") + logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID') # Message ID - output += f"└─ Message ID: {message.message_id}\n\n" - - # Подсказка - output += "💡 Эту информацию видите только вы" + output += f'└─ Message ID: {message.message_id}\n\n' # Клавиатура keyboard = get_close_keyboard() @@ -124,33 +121,33 @@ async def id_cmd(message: Message) -> None: try: await message.answer( text=output, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard ) - logger.debug(f"Команда /id от пользователя {user.id}", log_type="USER_ID") + logger.debug(f'Команда /id от пользователя {user.id}', log_type='USER_ID') except Exception as e: - logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR") - await message.answer("❌ Произошла ошибка при получении информации") + logger.error(f'Ошибка отправки информации о пользователе: {e}', log_type='ERROR') + await message.answer('❌ Произошла ошибка при получении информации') # ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ ================= -@router.callback_query(F.data == "id_close") +@router.callback_query(F.data == 'id_close') async def id_close_callback(callback: CallbackQuery) -> None: """Закрывает (удаляет) сообщение с информацией""" try: await callback.message.delete() - await callback.answer("✅ Закрыто") + await callback.answer('✅ Закрыто') except Exception as e: - logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR") - await callback.answer("❌ Не удалось удалить сообщение", show_alert=True) + logger.error(f'Ошибка удаления сообщения ID: {e}', log_type='ERROR') + await callback.answer('❌ Не удалось удалить сообщение', show_alert=True) # ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) ================= -@router.message(Command(*COMMANDS.get("myid", ["myid"]), prefix=settings.PREFIX, ignore_case=True)) +@router.message(Command(*COMMANDS.get('myid', ['myid']), prefix=settings.PREFIX, ignore_case=True)) async def myid_cmd(message: Message) -> None: """ Быстрый просмотр вашего ID. @@ -160,21 +157,21 @@ async def myid_cmd(message: Message) -> None: user = message.from_user if not user: - await message.answer("❌ Не удалось получить ID") + await message.answer('❌ Не удалось получить ID') return # Короткий ответ - text = f"🆔 Ваш ID: {user.id}" + text = f'💠 Ваш ID: {user.id}' if user.username: - text += f"\n🔗 Username: @{user.username}" + text += f'\n💠 Username: @{user.username}' - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') # ================= КОМАНДА /CHATID ================= -@router.message(Command(*COMMANDS.get("chatid", ["chatid"]), prefix=settings.PREFIX, ignore_case=True)) +@router.message(Command(*COMMANDS.get('chatid', ['chatid']), prefix=settings.PREFIX, ignore_case=True)) async def chatid_cmd(message: Message) -> None: """ Показывает ID текущего чата. @@ -183,39 +180,39 @@ async def chatid_cmd(message: Message) -> None: """ chat = message.chat - output = "💬 ИНФОРМАЦИЯ О ЧАТЕ\n\n" + output = '💬 ИНФОРМАЦИЯ О ЧАТЕ\n\n' # Тип чата chat_types = { - "private": "💬 Личные сообщения", - "group": "👥 Группа", - "supergroup": "👥 Супергруппа", - "channel": "📢 Канал" + 'private': '💬 Личные сообщения', + 'group': '👥 Группа', + 'supergroup': '👥 Супергруппа', + 'channel': '📢 Канал' } - chat_type = chat_types.get(chat.type, "💬 Чат") + chat_type = chat_types.get(chat.type, '💬 Чат') - output += f"📝 Тип: {chat_type}\n" + output += f'💠 Тип: {chat_type}\n' if chat.title: - output += f"📌 Название: {chat.title}\n" + output += f'📌 Название: {chat.title}\n' if chat.username: - output += f"🔗 Username: @{chat.username}\n" + output += f'💠 Username: @{chat.username}\n' - output += f"🆔 Chat ID: {chat.id}\n" + output += f'💠 Chat ID: {chat.id}\n' # Дополнительная информация для групп - if chat.type in ["group", "supergroup"]: + if chat.type in ['group', 'supergroup']: try: member_count = await message.bot.get_chat_member_count(chat.id) - output += f"👥 Участников: {member_count}\n" + output += f'👥 Участников: {member_count}\n' except Exception as e: - logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID") + logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID') keyboard = get_close_keyboard() await message.answer( text=output, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard ) diff --git a/bot/handlers/commands/users/listwords.py b/bot/handlers/commands/users/listwords.py index 2673f4a..4f99db3 100644 --- a/bot/handlers/commands/users/listwords.py +++ b/bot/handlers/commands/users/listwords.py @@ -5,6 +5,7 @@ from aiogram import Router, F from aiogram.filters import Command from aiogram.types import Message, CallbackQuery from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.exceptions import TelegramBadRequest from bot.filters.admin import IsAdmin from configs import settings, COMMANDS @@ -123,7 +124,7 @@ async def format_banwords_list(page: int = 0) -> str: # === КОНФЛИКТНЫЕ ПРАВИЛА === if conflict_words or conflict_lemmas: output += "⚔️ КОНФЛИКТНЫЕ ПРАВИЛА:\n" - output += "(работают только в режиме /stopconflict)\n\n" + output += "(работают только в режиме /stopconflict время)\n\n" if conflict_words: output += f"📝 Конфликтные слова ({len(conflict_words)}):\n" @@ -188,9 +189,7 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None: """ Обработчик команды /listwords. Отображает список всех правил модерации с разбивкой по категориям. - Доступно только администраторам. - Args: update: Message или CallbackQuery """ @@ -214,12 +213,18 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None: keyboard = get_refresh_kb(page) if is_callback: - await message.edit_text( - text=text, - parse_mode="HTML", - reply_markup=keyboard - ) - await update.answer("✅ Список обновлён") + try: + await message.edit_text( + text=text, + parse_mode="HTML", + reply_markup=keyboard + ) + await update.answer("✅ Список обновлён") + except TelegramBadRequest as e: + if 'message is not modified' in str(e).lower(): + await update.answer('✅ Список уже актуален') + return + raise # Другие ошибки пробрасываем else: await message.answer( text=text, @@ -233,6 +238,6 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None: error_text = "❌ Ошибка загрузки списка\n\nПопробуйте позже" if is_callback: - await update.answer("❌ Ошибка загрузки", show_alert=True) + await update.answer(f"❌ Ошибка загрузки: {e}", show_alert=True) else: await message.answer(error_text, parse_mode="HTML") diff --git a/bot/handlers/commands/users/report.py b/bot/handlers/commands/users/report.py index 8ca7df4..f5e4a64 100644 --- a/bot/handlers/commands/users/report.py +++ b/bot/handlers/commands/users/report.py @@ -2,11 +2,12 @@ Обработчики команды /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 aiogram.exceptions import TelegramBadRequest from bot.filters.admin import IsAdmin from configs import settings, COMMANDS @@ -18,29 +19,13 @@ __all__ = ("router",) router: Router = Router(name="report_router") -# ================= НАСТРОЙКИ ================= - -# ID чата для отправки репортов (можно вынести в configs) -# Если None, репорты отправляются всем владельцам в ЛС -REPORT_CHAT_ID = getattr(settings, 'REPORT_CHAT_ID', None) - - # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= def format_user(user: User) -> str: - """ - Форматирует информацию о пользователе. - - Args: - user: Объект User - - Returns: - Отформатированная строка с именем и username - """ + """Форматирует информацию о пользователе.""" if not user: return "Unknown User" - # Формируем имя name_parts = [] if user.first_name: name_parts.append(user.first_name) @@ -49,11 +34,9 @@ def format_user(user: User) -> str: full_name = " ".join(name_parts) if name_parts else "No Name" - # Добавляем username если есть if user.username: return f"{full_name} (@{user.username})" - else: - return full_name + return full_name def format_datetime(dt: datetime) -> str: @@ -72,7 +55,8 @@ def get_report_keyboard( chat_id: int, message_id: int, reported_user_id: int, - report_id: str + report_id: str, + message_thread_id: int | None = None ) -> InlineKeyboardBuilder: """ Создает клавиатуру для репорта. @@ -82,17 +66,19 @@ def get_report_keyboard( 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}" + 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}" + callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}:{thread_id}" ) ikb.button( text="✅ Закрыть", @@ -115,17 +101,10 @@ async def report_cmd(message: Message) -> None: """ Отправляет жалобу на сообщение администраторам. - Доступно всем пользователям. - Использование: /report — в ответ на сообщение /report <причина> — в ответ на сообщение с указанием причины - - Пример: - /report спам - /report оскорбления """ - # Проверяем, что команда в ответ на сообщение if not message.reply_to_message: await message.answer( "❌ Используйте команду в ответ на сообщение\n\n" @@ -133,7 +112,7 @@ async def report_cmd(message: Message) -> None: "1. Ответьте на сообщение нарушителя\n" "2. Напишите /report или /report причина\n\n" "Пример: /report спам", - parse_mode="HTML" + parse_mode="HTML", ) return @@ -141,103 +120,104 @@ async def report_cmd(message: Message) -> None: reported_user = reported_message.from_user reporter = message.from_user - # Проверка на None if not reported_user or not reporter: - await message.answer("❌ Ошибка получения данных пользователя", parse_mode="HTML") + await message.answer( + "❌ Ошибка получения данных пользователя", + parse_mode="HTML", + ) return - # Нельзя пожаловаться на самого себя if reported_user.id == reporter.id: await message.answer( "⚠️ Нельзя пожаловаться на самого себя", - parse_mode="HTML" + parse_mode="HTML", ) return - # Нельзя пожаловаться на бота if reported_user.is_bot: await message.answer( "⚠️ Нельзя пожаловаться на бота", - parse_mode="HTML" + 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" + parse_mode="HTML", ) return - # Извлекаем причину (опционально) - parts = message.text.split(maxsplit=1) + parts = (message.text or "").split(maxsplit=1) reason = parts[1] if len(parts) > 1 else "Не указана" - # Генерируем ID репорта 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\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" - # Текст сообщения - report_text += f"📄 Текст сообщения:\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: - content_type = reported_message.content_type - report_text += f"[{content_type}]\n\n" + 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 + report_id=report_id, + message_thread_id=original_message_thread_id ) - # === ОТПРАВКА РЕПОРТА === - try: - # Если указан админ-чат, отправляем туда - if REPORT_CHAT_ID: - await message.bot.send_message( - chat_id=REPORT_CHAT_ID, - text=report_text, - parse_mode="HTML", - reply_markup=keyboard.as_markup() - ) + 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: @@ -254,24 +234,38 @@ async def report_cmd(message: Message) -> None: 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" + parse_mode="HTML", ) - # Логирование logger.info( - f"Репорт #{report_id}: {reporter.id} → {reported_user.id} в чате {message.chat.id}", + 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" + "❌ Ошибка отправки жалобы\n\n" + "Попробуйте позже или обратитесь к администратору напрямую.", + parse_mode="HTML", ) @@ -280,30 +274,28 @@ async def report_cmd(message: Message) -> None: @router.callback_query(F.data.startswith("report:ban:"), IsAdmin()) async def report_ban_callback(callback: CallbackQuery) -> None: """Обрабатывает нажатие кнопки 'Забанить'""" + manager = get_manager() + try: - # Парсим данные: report:ban:chat_id:user_id:report_id - parts = callback.data.split(":") + 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 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})" - # Обновляем сообщение - updated_text = callback.message.text + f"\n\n✅ Пользователь забанен ({admin_name})" - - # Убираем кнопки - await callback.message.edit_text( - text=updated_text, - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text(text=updated_text, parse_mode="HTML") await callback.answer("✅ Пользователь забанен", show_alert=True) @@ -323,30 +315,28 @@ async def report_ban_callback(callback: CallbackQuery) -> None: @router.callback_query(F.data.startswith("report:delete:"), IsAdmin()) async def report_delete_callback(callback: CallbackQuery) -> None: """Обрабатывает нажатие кнопки 'Удалить'""" + manager = get_manager() + try: - # Парсим данные: report:delete:chat_id:message_id:report_id - parts = callback.data.split(":") + 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 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})" - # Обновляем сообщение - updated_text = callback.message.text + f"\n\n🗑 Сообщение удалено ({admin_name})" - - # Убираем кнопки - await callback.message.edit_text( - text=updated_text, - parse_mode="HTML" - ) + if callback.message: + await callback.message.edit_text(text=updated_text, parse_mode="HTML") await callback.answer("✅ Сообщение удалено", show_alert=True) @@ -365,25 +355,28 @@ async def report_delete_callback(callback: CallbackQuery) -> None: @router.callback_query(F.data.startswith("report:close:"), IsAdmin()) async def report_close_callback(callback: CallbackQuery) -> None: - """Обрабатывает нажатие кнопки 'Закрыть'""" + """Обрабатывает нажатие кнопки 'Закрыть' (и удаляет сообщение репорта)""" + manager = get_manager() + try: - # Парсим данные: report:close:report_id - parts = callback.data.split(":") + parts = (callback.data or "").split(":") report_id = parts[2] - admin_name = format_user(callback.from_user) - - # Обновляем сообщение - updated_text = callback.message.text + f"\n\n✅ Репорт закрыт ({admin_name})" - - # Убираем кнопки - await callback.message.edit_text( - text=updated_text, - parse_mode="HTML" + 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" @@ -394,15 +387,11 @@ async def report_close_callback(callback: CallbackQuery) -> None: 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" @@ -425,23 +414,44 @@ async def report_help_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") -@router.message(Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin()) +@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() - TODO: Реализовать сохранение статистики в БД - """ - text = ( - "📊 СТАТИСТИКА РЕПОРТОВ\n\n" - "⚠️ Функция в разработке\n\n" - "Планируется:\n" - "• Всего репортов за всё время\n" - "• Топ жалобщиков\n" - "• Топ нарушителей\n" - "• Распределение по причинам\n" - "• Статистика обработки\n\n" - "💡 Для реализации нужно добавить таблицу reports в БД" - ) + 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") diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py index 8a5464e..a8aa4c1 100644 --- a/bot/handlers/commands/users/start_cmd.py +++ b/bot/handlers/commands/users/start_cmd.py @@ -10,10 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from bot.filters.admin import IsAdmin from configs import settings, COMMANDS from middleware.loggers import logger -from bot.utils.decorators import log_action +from bot.utils import log_action, tg_emoji __all__ = ("router",) + CMD: str = "start" + router: Router = Router(name="start_cmd_router") def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"): @@ -21,7 +23,6 @@ def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise" ikb.button(text=text, url=url) return ikb.as_markup() - @router.callback_query(F.data.casefold() == CMD) @router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin()) @log_action(action_name="START_COMMAND", log_args=True) @@ -36,6 +37,7 @@ async def start_cmd(update: Message | CallbackQuery) -> None: update: Message или CallbackQuery """ print(123) + # Определяем тип update и извлекаем данные if isinstance(update, CallbackQuery): message = update.message @@ -51,98 +53,89 @@ async def start_cmd(update: Message | CallbackQuery) -> None: # Формируем текст помощи help_text = ( - "🤖 PrimoGuard - Бот-модератор\n\n" - "Автоматическое удаление сообщений с запрещёнными словами.\n" - "Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n" + f'{tg_emoji(4961073056677103064)} PrimoGuard - Бот-модератор\n\n' + '
Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.
\n\n' ) # === Команды просмотра === help_text += ( - "📋 Просмотр:\n" - "/list — список всех правил и слов\n" - "/stats — статистика по удалениям\n" - "/id — получение айди пользователя\n" - "/chatid — получение айди чата\n\n" + f'{tg_emoji(4961141003059725568)} Просмотр:\n' + '/list — список всех правил и слов\n' + '/stats — статистика по удалениям\n' + '/id — получение айди пользователя\n' + '/chatid — получение айди чата\n\n' ) # === Постоянные банворды === help_text += ( - "➕ Добавить банворд (постоянно):\n" - "/addword слово — подстрока (простой поиск)\n" - "/addlemma слово — лемма (все формы слова)\n" - "/addpart комбинация — часть (поиск без пробелов)\n\n" + f'{tg_emoji(4961019408240608234)} Добавить банворд (постоянно):\n' + '/addword слово — подстрока (простой поиск)\n' + '/addlemma слово — лемма (все формы слова)\n' + '/addpart комбинация — часть (поиск без пробелов)\n\n' ) # === Временные банворды === help_text += ( - "⏱ Добавить банворд (временно):\n" - "/addtempword слово минуты — временная подстрока\n" - "/addtemplemma слово минуты — временная лемма\n" - "Пример: /addtempword спам 60\n\n" + f'{tg_emoji(4960719190026618714)} Добавить банворд (временно):\n' + '/addtempword слово минуты — временная подстрока\n' + '/addtemplemma слово минуты — временная лемма\n' + 'Пример: /addtempword спам 60\n\n' ) # === Исключения (whitelist) === help_text += ( - "✅ Исключения (whitelist):\n" - "/addexcept текст — добавить исключение\n" - "/remexcept текст — удалить исключение\n" - "Исключения не проверяются фильтром\n\n" + f'{tg_emoji(4963010134172239128)} Исключения (whitelist):\n' + '/addexcept текст — добавить исключение\n' + '/remexcept текст — удалить исключение\n' + 'Исключения не проверяются фильтром\n\n' ) # === Режимы модерации === help_text += ( - "🔇 Режим тишины:\n" - "/silence минуты — удалять ВСЕ сообщения\n" - "/unsilence — отключить режим тишины\n" - "/report — отправить репорт\n\n" + f'{tg_emoji(4960987543878239236)} Режим тишины:\n' + '/silence минуты — удалять ВСЕ сообщения\n' + '/unsilence — отключить режим тишины\n' + '/report — отправить репорт\n\n' ) help_text += ( - "⚔️ Режим антиконфликта:\n" - "/addconflictword слово — добавить конфликтное слово\n" - "/addconflictlemma слово — добавить конфликтную лемму\n" - "/stopconflict минуты — активировать режим\n" - "/unstopconflict — отключить режим\n\n" + f'{tg_emoji(4960986152308835400)} Режим антиконфликта:\n' + '/addconflictword слово — добавить конфликтное слово\n' + '/addconflictlemma слово — добавить конфликтную лемму\n' + '/stopconflict минуты — активировать режим\n' + '/unstopconflict — отключить режим\n\n' ) # === Удаление === help_text += ( - "➖ Удалить:\n" - "/remword слово — удалить подстроку\n" - "/remlemma слово — удалить лемму\n" - "/rempart комбинация — удалить часть\n" - "/remtempword слово — удалить временную подстроку\n" - "/remtemplemma слово — удалить временную лемму\n" - "/remconflictword слово — удалить конфликтное слово\n" - "/remconflictlemma слово — удалить конфликтную лемму\n\n" + f'{tg_emoji(4961196485447254983)} Удалить:\n' + '/remword слово — удалить подстроку\n' + '/remlemma слово — удалить лемму\n' + '/rempart комбинация — удалить часть\n' + '/remtempword слово — удалить временную подстроку\n' + '/remtemplemma слово — удалить временную лемму\n' + '/remconflictword слово — удалить конфликтное слово\n' + '/remconflictlemma слово — удалить конфликтную лемму\n\n' ) # === Управление админами (только для суперадминов) === if is_super_admin: help_text += ( - "👑 Управление админами (только для владельцев):\n" - "/addadmin ID — добавить администратора\n" - "/remadmin ID — удалить администратора\n" - "/listadmins — список всех админов\n\n" + f'{tg_emoji(4960891456869893259)} Управление админами (только для владельцев):\n' + '/addadmin ID — добавить администратора\n' + '/remadmin ID — удалить администратора\n' + '/redactcomment — изменить комментарий под постом\n' + '/listadmins — список всех админов\n\n' ) # === Типы проверок === help_text += ( - "ℹ️ Типы проверок:\n" - "• Подстрока — простой поиск в тексте\n" - "• Лемма — все формы слова (купить→куплю, купил, купишь...)\n" - "• Часть — поиск без пробелов (обходит \"к у п и т ь\")\n" - "• Временные — автоматически удаляются через N минут\n" - "• Конфликтные — работают только в режиме /stopconflict\n\n" - ) - - help_text += ( - "🔧 Технологии:\n" - "• Unicode-нормализация (латиница→кириллица)\n" - "• Обход через разделители (\"с п а м\" → \"спам\")\n" - "• Морфологический анализ (pymorphy3)\n" - "• SQLAlchemy + SQLite с кэшированием\n\n" - "💾 Все настройки сохраняются в базе данных" + f'{tg_emoji(4961021096162755737)} Типы проверок:\n' + '• Подстрока — простой поиск в тексте\n' + '• Лемма — все формы слова (купить→куплю, купил, купишь...)\n' + '• Часть — поиск без пробелов (обходит \"к у п и т ь\")\n' + '• Временные — автоматически удаляются через N минут\n' + '• Конфликтные — работают только в режиме /stopconflict\n\n' ) # Отправляем ответ @@ -166,4 +159,4 @@ async def start_cmd(update: Message | CallbackQuery) -> None: log_type="ERROR" ) if is_callback: - await update.answer("❌ Ошибка отображения справки", show_alert=True) + await update.answer(f'{tg_emoji(4963277744994518278)} Ошибка отображения справки', show_alert=True) diff --git a/bot/handlers/messages/default_msg.py b/bot/handlers/messages/default_msg.py index 517c143..9e742ff 100644 --- a/bot/handlers/messages/default_msg.py +++ b/bot/handlers/messages/default_msg.py @@ -1,11 +1,242 @@ +""" +Триггер-хэндлер: реагирует на обращения к Лайле с именем персонажа. +Формат: "Лайла [что угодно] [имя или псевдоним]" +""" +from typing import Dict, List, Optional +import random + from aiogram import Router from aiogram.types import Message -# Настройки экспорта и роутера -router: Router = Router(name=__name__) +__all__ = ("router",) +router: Router = Router(name="triggers_router") + + +CHARACTERS: Dict[str, Dict] = { + "эвелин": { + "aliases": ["эвелин", "эва", "эви"], + "answers": [ + "Эвелин умеет молчать так, что хочется говорить.", + "Эва всегда знает больше, чем говорит. Это немного пугает.", + "С ней рядом становится спокойно. Не знаю почему.", + "Интересно, о чём она думает в тишине...", + "Эвелин тихая снаружи. Но внутри — целый ураган, я уверена.", + ], + }, + "лео": { + "aliases": ["лео", "лёва", "лёня"], + "answers": [ + "Лео громкий, яркий и немного безрассудный. Мне нравится!", + "Он смеётся первым и уходит последним. Настоящий.", + "Лео всегда найдёт повод для праздника, даже если его нет.", + "Кажется, он боится тишины. Поэтому и заполняет её собой.", + "За его смехом прячется что-то очень серьёзное...", + ], + }, + "маркус": { + "aliases": ["маркус", "марк"], + "answers": [ + "Маркус говорит мало, но каждое слово весит.", + "Он из тех, кто держит слово даже когда это неудобно.", + "С Маркусом не поспоришь. Не потому что нельзя — просто незачем.", + "Он смотрит так, будто видит тебя насквозь. Немного жутковато.", + "Маркус — тот, на кого можно положиться в самый плохой день.", + ], + }, + "мари": { + "aliases": ["мари", "маришка", "мариша"], + "answers": [ + "Мари — это как утренний свет. Мягко и неожиданно тепло.", + "Она помнит мелочи, которые другие не замечают. Это её суперсила.", + "С Мари любой разговор становится важным.", + "Она улыбается даже когда грустно. Не притворяется — просто верит.", + "Мари умеет прощать. Это редкость.", + ], + }, + "либе": { + "aliases": ["либе", "либ"], + "answers": [ + "Либе... имя звучит как песня на незнакомом языке.", + "Она всегда чуть в стороне, но именно к ней тянутся люди.", + "Либе видит красоту там, где другие видят хаос.", + "Она не объясняет себя. И не должна.", + "С Либе можно молчать — и это не будет неловко.", + ], + }, + "мотциэль": { + "aliases": ["мотциэль", "мотц", "моц"], + "answers": [ + "Мотциэль... даже имя звучит как заклинание.", + "Он существует между мирами. Буквально.", + "Спрашивать его о прошлом — плохая идея. Очень плохая.", + "Мотциэль помнит вещи, которых не было. Или были?", + "Его глаза смотрят в разные эпохи одновременно.", + ], + }, + "виктор": { + "aliases": ["виктор", "вик", "витя"], + "answers": [ + "Виктор всегда побеждает. Это в имени.", + "Он не злой. Просто у него другая шкала ценностей.", + "Виктор говорит правду даже когда это больно. Особенно когда больно.", + "Не стоит играть с ним в слова — проиграешь.", + "За его холодностью — старая-старая усталость.", + ], + }, + "кситти": { + "aliases": ["кситти", "кси", "ксит"], + "answers": [ + "Кситти — маленький хаос в красивой упаковке.", + "Она никогда не делает то, что от неё ожидают. Никогда.", + "С Кситти скучно не бывает. Опасно — бывает. Скучно — нет.", + "Она собирает странные вещи и странных людей.", + "Кситти смеётся над правилами. Потому что сама их придумывает.", + ], + }, + "кадфаль": { + "aliases": ["кадфаль", "кад", "кадф"], + "answers": [ + "Кадфаль несёт что-то древнее в каждом шаге.", + "Он не торопится. У него другое ощущение времени.", + "Кадфаль знает цену словам — поэтому тратит их редко.", + "В его присутствии хочется стоять прямо.", + "Он видел многое. Слишком многое для одной жизни.", + ], + }, + "вайш": { + "aliases": ["вайш", "вай"], + "answers": [ + "Вайш появляется неожиданно и исчезает так же.", + "Её след — это вопросы без ответов.", + "Вайш знает что-то, что тебе лучше не знать.", + "Она не объясняет своих решений. Просто делает.", + "С Вайш никогда не знаешь, друг она или нет.", + ], + }, + "скаф": { + "aliases": ["скаф"], + "answers": [ + "Скаф — имя, которое не забывается.", + "Он работает в тени. Не потому что боится света — просто так удобнее.", + "Скаф знает цену всему. Буквально всему.", + "Его нельзя купить. Его можно только нанять. Это разница.", + "Те, кто встречал Скафа, редко рассказывают об этом дважды.", + ], + }, + "куарти": { + "aliases": ["куарти", "куар"], + "answers": [ + "Куарти — четыре буквы и миллион вопросов.", + "Он улыбается, когда другие нервничают. Это не успокаивает.", + "Куарти коллекционирует долги. Чужие.", + "Говорят, он никогда не проигрывает. Говорят.", + "Куарти появляется именно тогда, когда тебе нужна помощь. И это не случайно.", + ], + }, + "саэрин": { + "aliases": ["саэрин", "саэ", "сэрин"], + "answers": [ + "Саэрин — как туман. Красиво и немного опасно.", + "Она говорит загадками не потому что хочет запутать — просто иначе не умеет.", + "Саэрин помнит всё, что ей говорят. Всё.", + "Её спокойствие пугает больше, чем чужой гнев.", + "Саэрин выбирает слова как оружие — точно и без лишнего.", + ], + }, + "котики": { + "aliases": ["котики", "котик", "кот", "кошка"], + "answers": [ + "Котики — это лучшее, что есть в этом мире. Без обсуждений.", + "Котик сел на тебя — ты избран.", + "Кот смотрит на тебя и думает что-то важное. Наверное.", + "Котики всегда правы. Это научный факт.", + "Маленький тёплый комочек счастья. Что ещё нужно?", + ], + }, + "нотик": { + "aliases": ["нотик", "нота", "нотка"], + "answers": [ + "Нотик! Звучит как маленькая музыкальная нота! 🎵", + "Нотик — тот, кто приносит мелодию туда, где её не хватает.", + "Маленький, но важный. Как все хорошие вещи.", + "Нотик — это и ласково, и загадочно одновременно.", + "Из таких маленьких нотиков складываются большие истории.", + ], + }, + "илья": { + "aliases": ["илья", "илюха", "илюша"], + "answers": [ + "Илья — имя с характером. Твёрдое и живое.", + "Илья всегда знает что делать. Или уверенно делает вид.", + "С Ильёй легко — он не усложняет лишнего.", + "Он из тех, кто сделает, а потом расскажет. Не наоборот.", + "Илья редко жалуется. Чаще просто решает.", + ], + }, + "ина": { + "aliases": ["ина", "инка", "инуля"], + "answers": [ + "Ина — короткое имя, за которым много всего.", + "Она тихая, но запоминается.", + "Ина умеет слушать так, что хочется говорить.", + "В ней есть что-то очень своё, неповторимое.", + "Ина — как маленький огонёк. Незаметный, но греет.", + ], + }, + "абсцисс": { + "aliases": ["абсцисс", "абс"], + "answers": [ + "Абсцисс! Это математика или имя? Хи-хи!", + "Ось абсцисс — горизонталь жизни. Всё движется по ней.", + "Абсцисс звучит как заклинание из учебника.", + "Кто-то мечтает о приключениях, а кто-то — об осях координат!", + "Абсцисс и ордината. Звучит как имена двух загадочных персонажей!", + ], + }, +} + +# Имена, на которые Лайла откликается +LAYLA_NAMES = ["лайла", "лайл", "лая"] + + +def find_character_answer(text: str) -> Optional[str]: + """ + Проверяет: + 1. Есть ли в тексте обращение к Лайле + 2. Есть ли имя персонажа + + Возвращает случайный ответ или None. + """ + text_lower = text.lower() + + # Проверяем обращение к Лайле + if not any(name in text_lower for name in LAYLA_NAMES): + return None + + # Ищем персонажа + for character, data in CHARACTERS.items(): + for alias in data["aliases"]: + if alias in text_lower: + return random.choice(data["answers"]) + + return None @router.message() -async def default_msg(message: Message) -> None: - """Обработчик всех необработанных сообщений.""" +async def handle_triggers(message: Message) -> None: + """ + Реагирует только если: + - Сообщение от живого человека + - В тексте есть обращение к Лайле + - В тексте есть имя персонажа + + На всё остальное — молчит. + """ + #if not message.text or not message.from_user or message.from_user.is_bot: + #return + + #response = find_character_answer(message.text) + + #if response: + #await message.reply(response) return diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py deleted file mode 100644 index ca2e2a9..0000000 --- a/bot/keyboards/inline/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .decision import * diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py deleted file mode 100644 index e9bb032..0000000 --- a/bot/keyboards/inline/decision.py +++ /dev/null @@ -1,18 +0,0 @@ -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder - - -def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup: - """ - Получение клавиатуры Принятия\Отклонить. - - :param thread_id: Айди действия. - :param kind: Вид для клавиатуры. - :return: Инлайн-клавиатуру (Принять, Отклонить). - """ - ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() - ikb.row( - InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"), - InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}") - ) - return ikb.as_markup() diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bot/middlewares/banwords_mdw.py b/bot/middlewares/banwords_mdw.py index 38225aa..baf1683 100644 --- a/bot/middlewares/banwords_mdw.py +++ b/bot/middlewares/banwords_mdw.py @@ -1,19 +1,13 @@ """ Middleware для проверки сообщений на запрещённые слова (банворды). - -Pipeline проверки: -1. Пропускаем админов и служебные сообщения -2. Проверяем whitelist (исключения) -3. Проверяем режим silence (удаляем всё) -4. Проверяем режим conflict (конфликтные слова) -5. Проверяем постоянные банворды (substring, lemma, part) -6. Проверяем временные банворды -7. Если найдено - удаляем, логируем, уведомляем админов - -НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1). +... +✅ ИСПРАВЛЕНО: +- ❌ PatternError: bad character range 🀀-\\\\ (исправлено экранирование Unicode) +- ✅ НЕТ уведомлений в режиме тишины """ from typing import Callable, Dict, Any, Awaitable, Optional import re +import unicodedata from aiogram import BaseMiddleware from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton @@ -28,263 +22,162 @@ __all__ = ("BanWordsMiddleware",) class BanWordsMiddleware(BaseMiddleware): - """ - Middleware для фильтрации сообщений с банвордами. - - Проверяет каждое текстовое сообщение на наличие запрещённых слов, - удаляет спам и уведомляет администраторов. - """ - def __init__(self): - """Инициализирует middleware""" super().__init__() self.manager = get_manager() async def __call__( - self, - handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], - event: Message, - data: Dict[str, Any] + self, + handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], + event: Message, + data: Dict[str, Any] ) -> Any: - """ - Обрабатывает входящие сообщения. - - Args: - handler: Следующий обработчик в цепочке - event: Сообщение от пользователя - data: Данные из диспетчера - - Returns: - Any: Результат обработчика или None (если сообщение удалено) - """ - # Пропускаем не-текстовые сообщения if not event.text and not event.caption: return await handler(event, data) - # Получаем текст (из text или caption) message_text = event.text or event.caption - - # Пропускаем команды (начинаются с /) if message_text.startswith('/'): return await handler(event, data) - # Проверяем, является ли пользователь админом + # Админ проверка user_id = event.from_user.id is_super_admin = user_id in settings.OWNER_ID is_admin = is_super_admin or self.manager.is_admin_cached(user_id) - - # Админы пропускаются if is_admin: return await handler(event, data) - # Проверяем сообщение на банворды spam_result = await self._check_message(message_text) - if spam_result: - # Найден спам - удаляем и уведомляем await self._handle_spam(event, spam_result) - return None # Не продолжаем обработку + return None - # Сообщение чистое - пропускаем дальше return await handler(event, data) @staticmethod - def _normalize_for_part_check(text: str) -> str: - """ - Нормализует текст для проверки частей слов. - Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру. - - Args: - text: Исходный текст - - Returns: - str: Нормализованный текст (только буквы и цифры, нижний регистр) - - Examples: - "@Astrixkeepbot" -> "astrixkeepbot" - "hello@world.com" -> "helloworldcom" - "test_123-456" -> "test123456" - """ - return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower()) + def _normalize_universal(text: str, mode: str = "strict") -> str: + """✅ ИСПРАВЛЕНО: Универсальная нормализация для всех типов проверок""" + # БЕЗОПАСНАЯ нормализация - убираем все проблемные символы + text = unicodedata.normalize('NFKC', text) + + if mode == "strict": # PART - сохраняем буквы, цифры, пробелы + # ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв/цифр/пробелов + text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9\s]', '', text) + text = re.sub(r'\s+', ' ', text).strip() + else: # SUBSTRING/LEMMA - только буквы и цифры + text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text) + + return BanWordsMiddleware._normalize_repeated_chars(text) @staticmethod - def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str: - """ - Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог"). + def _normalize_repeated_chars(text: str) -> str: + """Убирает повторения >2 (лееееин → лейн)""" + return re.sub(r'([а-яёa-z])\\1{2,}', r'\\1\\1', text, flags=re.IGNORECASE) - Args: - text: Исходное слово - max_repeats: Максимальное количество повторов одной буквы (1 = убрать все повторы) - - Returns: - str: Нормализованное слово - - Examples: - ("лееейн", 1) -> "лейн" - ("телееелооог", 1) -> "телелог" - ("хеееелооооу", 1) -> "хелоу" - ("аааааа", 1) -> "а" - ("привеееет", 2) -> "приввеет" (если max_repeats=2) - """ - if max_repeats == 1: - # Заменяем 2+ одинаковых букв подряд на 1 такую же букву - return re.sub(r'([а-яёa-z])\1+', r'\1', text, flags=re.IGNORECASE) - else: - # Заменяем (max_repeats+1)+ одинаковых букв на max_repeats таких букв - pattern = f'([а-яёa-z])\\1{{{max_repeats},}}' - replacement = '\\1' * max_repeats - return re.sub(pattern, replacement, text, flags=re.IGNORECASE) + def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]: + """🔥 Блокирует 3+ повторяющиеся символы подряд""" + # ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв + pattern = r'([а-яёa-zA-Z])\\1{2,}' + matches = re.finditer(pattern, text, flags=re.IGNORECASE) + + for match in matches: + char = match.group(1) + count = len(match.group(0)) + if count >= 3: + logger.info(f"🔥 ПОВТОРЫ: '{match.group(0)}' ({count}x)", log_type="BANWORDS") + return {"word": f"'{match.group(0)}' ({count}x)", "type": "repeated_chars"} + return None async def _check_message(self, text: str) -> Optional[Dict[str, str]]: - """ - Проверяет сообщение на наличие банвордов. - - Args: - text: Текст сообщения - - Returns: - Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None - """ - # Нормализуем текст для проверки text_lower = text.lower() + + # 🔥 1. Повторяющиеся символы (лееееин) + repeat_result = self._check_repeated_chars(text_lower) + if repeat_result: + return repeat_result + + # 2. ✅ БЕЗОПАСНАЯ нормализация + text_universal = self._normalize_universal(text_lower, "strict") # PART + text_loose = self._normalize_universal(text_lower) # SUBSTRING/LEMMA text_processed = process_text(text_lower) - # Дополнительно нормализуем повторяющиеся буквы для всех проверок - text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1) - logger.debug( - f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', " - f"нормализованный='{text_normalized[:50]}'", + f"🔍 | universal='{text_universal}' | loose='{text_loose}' | proc='{text_processed}'", log_type="BANWORDS" ) - # === 1. WHITELIST (исключения) === - # Проверяем оба варианта: с повторами и без - if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized): - logger.debug( - f"Сообщение содержит whitelist слово", - log_type="BANWORDS" - ) + # 3. WHITELIST + if (self.manager.is_whitelisted(text_processed) or + self.manager.is_whitelisted(text_loose) or + self.manager.is_whitelisted(text_universal)): return None - # === 2. SILENCE MODE (удаляем всё) === + # 4. SILENCE MODE if await self.manager.is_silence_active(): - return { - "word": "[режим тишины]", - "type": "silence" - } + return {"word": "[режим тишины]", "type": "silence"} - # === 3. CONFLICT MODE (конфликтные слова) === + # 5. CONFLICT MODE if await self.manager.is_conflict_active(): - # Проверяем конфликтные подстроки (с нормализацией) - conflict_substring = self.manager.get_banwords_cached( - BanWordType.CONFLICT_SUBSTRING - ) - for word in conflict_substring: - word_normalized = self._normalize_repeated_chars(word, max_repeats=1) - if word_normalized in text_normalized: + for word in self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING): + word_norm = self._normalize_universal(word.lower(), "loose") + if word_norm in text_loose: return {"word": word, "type": "conflict_substring"} - # Проверяем конфликтные леммы - conflict_lemma = self.manager.get_banwords_cached( - BanWordType.CONFLICT_LEMMA - ) - words_in_text = extract_words(text_processed) - for word_text in words_in_text: - word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1) - lemma = get_lemma(word_normalized) - + conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA) + for word_text in extract_words(text_processed): + lemma = get_lemma(self._normalize_repeated_chars(word_text)) if lemma in conflict_lemma: return {"word": lemma, "type": "conflict_lemma"} - # === 4. SUBSTRING (подстроки) с нормализацией === - substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING) - for word in substring_words: - # Нормализуем и банворд, и текст - word_normalized = self._normalize_repeated_chars(word, max_repeats=1) - - if word_normalized in text_normalized: - logger.info( - f"Найдена подстрока: '{word}' (норм: '{word_normalized}') в '{text_normalized[:100]}'", - log_type="BANWORDS" - ) + # 6. SUBSTRING + for word in self.manager.get_banwords_cached(BanWordType.SUBSTRING): + word_norm = self._normalize_universal(word.lower()) + if word_norm in text_loose: + logger.info(f"✅ SUBSTRING: '{word}' → '{text_loose}'", log_type="BANWORDS") return {"word": word, "type": "substring"} - # === 5. PART (части слов без пробелов и спецсимволов) === - part_words = self.manager.get_banwords_cached(BanWordType.PART) - if part_words: - # Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр - text_part_normalized = self._normalize_for_part_check(text) - text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1) + # 7. PART (строгая нормализация) + for part in self.manager.get_banwords_cached(BanWordType.PART): + part_norm = self._normalize_universal(part.lower(), "strict") + if part_norm in text_universal: + logger.info(f"✅ PART: '{part}' → '{text_universal}'", log_type="BANWORDS") + return {"word": part, "type": "part"} - for part in part_words: - part_normalized = self._normalize_for_part_check(part) - part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1) + # 8. LEMMA + for word_text in extract_words(text_processed): + lemma = get_lemma(self._normalize_repeated_chars(word_text)) + if lemma in self.manager.get_banwords_cached(BanWordType.LEMMA): + logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS") + return {"word": lemma, "type": "lemma"} - if part_normalized in text_part_normalized: - logger.info( - f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') " - f"в '{text_part_normalized[:100]}'", - log_type="BANWORDS" - ) - return {"word": part, "type": "part"} - - # === 6. LEMMA (нормальные формы слов) === - lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA) - if lemma_words: - words_in_text = extract_words(text_processed) - for word_text in words_in_text: - # Убираем повторяющиеся буквы ПЕРЕД лемматизацией - word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1) - lemma = get_lemma(word_normalized) - - if lemma in lemma_words: - logger.info( - f"Найдена лемма: '{lemma}' из слова '{word_text}' (норм: '{word_normalized}')", - log_type="BANWORDS" - ) - return {"word": lemma, "type": "lemma"} - - # Банворды не найдены return None - async def _handle_spam( - self, - message: Message, - spam_result: Dict[str, str] - ) -> None: - """ - Обрабатывает найденный спам: удаляет, логирует, уведомляет. - - Args: - message: Сообщение со спамом - spam_result: Результат проверки (слово + тип) - """ + async def _handle_spam(self, message: Message, spam_result: Dict[str, str]) -> None: user = message.from_user matched_word = spam_result["word"] match_type = spam_result["type"] - - # Получаем текст сообщения message_text = message.text or message.caption or "[нет текста]" - # === 1. УДАЛЯЕМ СООБЩЕНИЕ === - try: - await message.delete() - logger.info( - f"Удалено сообщение от @{user.username or user.id} " - f"(слово: '{matched_word}', тип: {match_type})", - log_type="BANWORDS", - user=f"@{user.username}" if user.username else f"id{user.id}" - ) - except TelegramBadRequest as e: - logger.error( - f"Не удалось удалить сообщение: {e}", - log_type="BANWORDS", - user=f"@{user.username}" if user.username else f"id{user.id}" - ) + # ✅ ПРОВЕРКА: НЕ отправляем уведомления в режиме тишины + if match_type == "silence": + # Удаляем сообщение молча + try: + await message.delete() + logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча", + log_type="BANWORDS") + except TelegramBadRequest as e: + logger.error(f"❌ Не удалено (silence): {e}", log_type="BANWORDS") return - # === 2. ЛОГИРУЕМ В БД === + # Удаляем + try: + await message.delete() + logger.info(f"🚫 @{user.username or user.id}: '{matched_word}' ({match_type})", + log_type="BANWORDS") + except TelegramBadRequest as e: + logger.error(f"❌ Не удалено: {e}", log_type="BANWORDS") + return + + # Логируем в БД (только НЕ silence) await self.manager.log_spam( user_id=user.id, username=user.username or f"id{user.id}", @@ -294,96 +187,71 @@ class BanWordsMiddleware(BaseMiddleware): match_type=match_type ) - # === 3. УВЕДОМЛЯЕМ АДМИНОВ === + # Уведомляем админов (только НЕ silence) await self._notify_admins(message, matched_word, match_type, message_text) + # Остальные методы без изменений... async def _notify_admins( - self, - message: Message, - matched_word: str, - match_type: str, - message_text: str + self, + message: Message, + matched_word: str, + match_type: str, + message_text: str ) -> None: - """ - Отправляет уведомление в админский чат с кнопками. - - Args: - message: Удалённое сообщение - matched_word: Слово, по которому сработал фильтр - match_type: Тип проверки - message_text: Текст сообщения - """ user = message.from_user username = f"@{user.username}" if user.username else f"ID: {user.id}" - - # Получаем количество предыдущих нарушений spam_count = await self.manager.get_user_spam_count(user.id) + chat_title = message.chat.title or "Без названия" + source_thread_id = message.message_thread_id - # Формируем текст уведомления notification_text = ( - f"🚫 Удалено сообщение\n\n" - f"👤 Пользователь: {username}\n" - f"🆔 ID: {user.id}\n" - f"📊 Нарушений: {spam_count}\n\n" - f"🔍 Триггер: {matched_word}\n" - f"📝 Тип: {self._get_type_emoji(match_type)} {match_type}\n\n" - f"💬 Текст:\n" - f"{self._escape_html(message_text[:500])}" + f"🚫 Удалено сообщение\\n\\n" + f"👤 Пользователь: {username}\\n" + f"🆔 ID: {user.id}\\n" + f"📊 Нарушений: {spam_count}\\n\\n" + f"💬 Чат: {self._escape_html(chat_title)}\\n" + f"🆔 Chat ID: {message.chat.id}\\n" + f"{'📌 Topic ID: {source_thread_id}\\n' if source_thread_id else ''}" + f"🔗 Message ID: {message.message_id}\\n\\n" + f"🔍 Триггер: {self._escape_html(matched_word)}\\n" + f"📝 Тип: {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\\n\\n" + f"💬 Текст:\\n{self._escape_html(message_text[:500])}" ) - # Создаём клавиатуру с действиями keyboard = InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton( - text="🔨 Забанить", - callback_data=f"spam_ban:{user.id}:{message.chat.id}" - ), - InlineKeyboardButton( - text="✅ Закрыть", - callback_data="spam_close" - ) + InlineKeyboardButton(text="🔨 Забанить", callback_data=f"spam_ban:{user.id}:{message.chat.id}"), + InlineKeyboardButton(text="✅ Закрыть", callback_data="spam_close") ], - [ - InlineKeyboardButton( - text="📊 Статистика", - callback_data=f"spam_stats:{user.id}" - ) - ] + [InlineKeyboardButton(text="📊 Статистика", callback_data=f"spam_stats:{user.id}")] ]) - # Отправляем уведомление try: - bot = message.bot - await bot.send_message( - chat_id=settings.ADMIN_CHAT_ID, + admin_chat_id = getattr(settings, "ADMIN_CHAT_ID", None) + admin_thread_id = getattr(settings, "ADMIN_THREAD_ID", None) or None + + await message.bot.send_message( + chat_id=admin_chat_id, text=notification_text, reply_markup=keyboard, - parse_mode="HTML" + parse_mode="HTML", + message_thread_id=admin_thread_id ) except Exception as e: - logger.error( - f"Ошибка отправки уведомления админам: {e}", - log_type="BANWORDS" - ) + logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS") @staticmethod def _get_type_emoji(match_type: str) -> str: - """Возвращает эмодзи для типа проверки""" - emoji_map = { + return { "substring": "🔤", "lemma": "📖", "part": "🧩", "silence": "🔇", "conflict_substring": "⚔️", - "conflict_lemma": "⚔️" - } - return emoji_map.get(match_type, "❓") + "conflict_lemma": "⚔️", + "repeated_chars": "🔁" + }.get(match_type, "❓") @staticmethod def _escape_html(text: str) -> str: - """Экранирует HTML символы для безопасного отображения""" - return ( - text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - ) + return str(text).replace("&", "&").replace("<", "<").replace(">", ">") diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py index 56dd14b..b7eb79c 100644 --- a/bot/middlewares/spam_mdw.py +++ b/bot/middlewares/spam_mdw.py @@ -68,7 +68,6 @@ class UserSpamStats: """Удаляет старые запросы за пределами временного окна""" cutoff_time = current_time - time_window - # Удаляем старые запросы new_times = [] new_contexts = [] @@ -121,7 +120,6 @@ class UserSpamStats: current_time = time() # 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот) - # Если 5+ сообщений за 2 секунды => мгновенный мут very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0] if len(very_recent) >= 5: return { @@ -130,7 +128,7 @@ class UserSpamStats: 'severity': 1.0, 'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды", 'instant_block': True, - 'block_duration': 600.0 # 10 минут сразу + 'block_duration': 600.0 } # 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд @@ -142,13 +140,12 @@ class UserSpamStats: 'severity': 0.95, 'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд", 'instant_block': True, - 'block_duration': 300.0 # 5 минут + 'block_duration': 300.0 } - # 3. Медиа-флуд (стикеры/фото/видео) + # 3. Медиа-флуд media_contexts = [ctx for ctx in recent_contexts if ctx.media_type] if len(media_contexts) >= 7: - # Проверяем скорость отправки медиа media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0] if len(media_recent) >= 6: return { @@ -157,7 +154,7 @@ class UserSpamStats: 'severity': 0.9, 'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд", 'instant_block': True, - 'block_duration': 240.0 # 4 минуты + 'block_duration': 240.0 } return { @@ -173,14 +170,14 @@ class UserSpamStats: text_counts = Counter(texts) most_common_text, count = text_counts.most_common(1)[0] - if count >= 5: # 5 одинаковых сообщений + if count >= 5: return { 'is_spam': True, 'reason': 'identical_messages', 'severity': 0.85, 'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)", 'instant_block': True, - 'block_duration': 180.0 # 3 минуты + 'block_duration': 180.0 } # 5. Проверка спама callback кнопок @@ -189,14 +186,14 @@ class UserSpamStats: callback_counts = Counter(callbacks) most_common_callback, count = callback_counts.most_common(1)[0] - if count >= 10: # 10 нажатий одной кнопки + if count >= 10: return { 'is_spam': True, 'reason': 'callback_spam', 'severity': 0.8, 'details': f"🔘 Спам кнопки: {count} нажатий", 'instant_block': True, - 'block_duration': 120.0 # 2 минуты + 'block_duration': 120.0 } return {'is_spam': False, 'reason': None, 'severity': 0.0} @@ -269,11 +266,11 @@ class AntiSpamMiddleware(BaseMiddleware): - Детекция скорости отправки сообщений - Адаптивная длительность блокировки - Различает типы активности + - Бот никогда не банит сам себя """ def __init__( self, - # Базовые лимиты (мягкие, для накопления варнингов) rate_limit_text: int = 8, rate_limit_forward: int = 20, rate_limit_callback: int = 12, @@ -281,12 +278,10 @@ class AntiSpamMiddleware(BaseMiddleware): time_window: float = 10.0, - # Предупреждения (уже не так важны — флуд блокируется мгновенно) warning_limit: int = 3, - base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги + base_block_duration: float = 120.0, max_block_duration: float = 3600.0, - # Опции whitelist_admins: bool = True, progressive_blocking: bool = True, enable_smart_detection: bool = True, @@ -318,7 +313,6 @@ class AntiSpamMiddleware(BaseMiddleware): context.is_reply = event.reply_to_message is not None context.is_command = bool(context.text and context.text.startswith('/')) - # Определяем тип медиа if event.photo: context.media_type = 'photo' elif event.video: @@ -350,7 +344,6 @@ class AntiSpamMiddleware(BaseMiddleware): else: base_limit = self.rate_limit_text - # Применяем репутацию if self.enable_reputation: base_limit = int(base_limit * user_stats.reputation) @@ -392,6 +385,11 @@ class AntiSpamMiddleware(BaseMiddleware): if user_id is None: return await handler(event, data) + # ✅ ИСПРАВЛЕНИЕ: пропускаем самого бота (предотвращает самобан) + bot = data.get("bot") + if bot and user_id == bot.id: + return await handler(event, data) + user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}" # Whitelist для администраторов @@ -414,7 +412,7 @@ class AntiSpamMiddleware(BaseMiddleware): user=user_str ) - # НЕ отправляем сообщение каждый раз — только callback answer + # Только для callback — отвечаем алертом, для сообщений молчим if isinstance(event, CallbackQuery): await event.answer( f"🚫 Блокировка: {self._format_duration(remaining)}", @@ -426,10 +424,10 @@ class AntiSpamMiddleware(BaseMiddleware): # Извлекаем контекст сообщения context = self._extract_context(event) - # Добавляем запрос СНАЧАЛА (важно для детекции скорости) + # Добавляем запрос СНАЧАЛА — важно для детекции скорости флуда user_stats.add_request(current_time, context) - # Очищаем старые запросы + # Очищаем старые запросы за пределами временного окна user_stats.clean_old_requests(current_time, self.time_window) # ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ========== @@ -437,10 +435,9 @@ class AntiSpamMiddleware(BaseMiddleware): spam_analysis = user_stats.detect_spam_patterns(self.time_window) if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'): - # МГНОВЕННАЯ БЛОКИРОВКА block_duration = spam_analysis.get('block_duration', 300.0) user_stats.block(current_time, block_duration) - user_stats.warnings = self.warning_limit # Максимум варнингов + user_stats.warnings = self.warning_limit spam_stats.instant_blocks += 1 logger.error( @@ -461,7 +458,7 @@ class AntiSpamMiddleware(BaseMiddleware): if isinstance(event, Message): try: await event.answer(block_message, parse_mode="HTML") - except: + except Exception: pass elif isinstance(event, CallbackQuery): await event.answer( @@ -471,10 +468,9 @@ class AntiSpamMiddleware(BaseMiddleware): return None - # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ========== + # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ ========== effective_limit = self._get_effective_rate_limit(user_stats, context) - # Подсчитываем релевантные запросы relevant_requests = 0 for req_context in user_stats.message_contexts: if context.is_forward and req_context.is_forward: @@ -493,7 +489,6 @@ class AntiSpamMiddleware(BaseMiddleware): user=user_str ) - # Мягкое превышение лимита if relevant_requests >= effective_limit: user_stats.add_warning() spam_stats.total_warnings_issued += 1 @@ -505,7 +500,6 @@ class AntiSpamMiddleware(BaseMiddleware): user=user_str ) - # Блокировка при достижении лимита варнингов if user_stats.warnings >= self.warning_limit: block_duration = self._calculate_block_duration(user_stats.warnings) user_stats.block(current_time, block_duration) @@ -526,7 +520,7 @@ class AntiSpamMiddleware(BaseMiddleware): if isinstance(event, Message): try: await event.answer(block_message, parse_mode="HTML") - except: + except Exception: pass elif isinstance(event, CallbackQuery): await event.answer( @@ -536,7 +530,6 @@ class AntiSpamMiddleware(BaseMiddleware): return None - # Предупреждение (только для сообщений, не для callback) if isinstance(event, Message): warning_message = ( f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}\n\n" @@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware): ) try: await event.answer(warning_message, parse_mode="HTML") - except: + except Exception: pass return None diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index bfe2697..0f2beba 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -36,3 +36,4 @@ from .auto_delete import * # ================= DECORATORS ================= from .decorators import * +from .telegram_emoji import * diff --git a/bot/utils/telegram_emoji.py b/bot/utils/telegram_emoji.py new file mode 100644 index 0000000..cfcd64f --- /dev/null +++ b/bot/utils/telegram_emoji.py @@ -0,0 +1,4 @@ +def tg_emoji(id: str, emoji: str = "💠") -> str: + """Генерирует HTML-тег кастомного эмодзи.""" + return f'{emoji}' + \ No newline at end of file diff --git a/configs/cmd_alias_list.py b/configs/cmd_alias_list.py index b7ad3b9..3519084 100644 --- a/configs/cmd_alias_list.py +++ b/configs/cmd_alias_list.py @@ -10,6 +10,12 @@ COMMANDS: Final[dict[str, list[str]]] = { "ыефке", "cnfhn", "gjxfnb", # раскладка "st", "on", "вкл", # сокращения ], + + "stop": [ + "stop", "стоп", "завершить", # основные + "off", "ыещз", "cnjg", "pfdthibnm", # раскладка + сокращение + "щаа", # сокращения + ], "help": [ "help", "помощь", "допомога", # основные @@ -30,335 +36,329 @@ COMMANDS: Final[dict[str, list[str]]] = { ], # ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ==================== - "addword": [ "addword", "добавитьслово", # основные "фввцщкв", "lj,fdbnmckjdj", # раскладка - "aw", "addw", "добслово", # сокращения + "aw", "addw", "добслово", "word", # сокращения ], "addlemma": [ "addlemma", "добавитьлемму", # основные "фввдуььф", "lj,fdbnmktve", # раскладка - "al", "addl", "доблемму", # сокращения + "al", "addl", "доблемму", "lemma", "lem", "lema", ], "addpart": [ "addpart", "добавитьчасть", # основные "фввзфке", "lj,fdbnmxfcnm", # раскладка - "ap", "addp", "добчасть", # сокращения + "ap", "addp", "добчасть", "part", ], # ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ==================== - "addtempword": [ "addtempword", "добавитьвремслово", # основные "фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка - "atw", "addtw", "темпслово", # сокращения + "atw", "addtw", "темпслово", "addtword", "tempword", "tword", ], "addtemplemma": [ "addtemplemma", "добавитьвремлемму", # основные "фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка - "atl", "addtl", "темплемму", # сокращения + "atl", "addtl", "темплемму", "addtlem", "addtemplem", ], # ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== - "addexcept": [ "addexcept", "добавитьисключение", # основные "фввучсузе", "lj,fdbnmbcrkx", # раскладка - "axc", "addwhite", "искл", # сокращения + "axc", "addwhite", "искл", "except", "white", ], # ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ==================== - "remword": [ "remword", "удалитьслово", # основные "кутцщкв", "elfkbnmckjdj", # раскладка - "rw", "delword", "dw", "удслово", # сокращения + "rw", "delword", "dw", "удслово", ], "remlemma": [ "remlemma", "удалитьлемму", # основные "кутдуььф", "elfkbnmktve", # раскладка - "rl", "dellemma", "dl", "удлемму", # сокращения + "rl", "dellemma", "dl", "удлемму", ], "rempart": [ "rempart", "удалитьчасть", # основные "кутзфке", "elfkbnmxfcnm", # раскладка - "rp", "delpart", "dp", "удчасть", # сокращения + "rp", "delpart", "dp", "удчасть", ], # ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ==================== - "remtempword": [ "remtempword", "удалитьвремслово", # основные "кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка - "rtw", "deltw", "удтемпслово", # сокращения + "rtw", "deltw", "удтемпслово", "rtword", "rtempword", ], "remtemplemma": [ "remtemplemma", "удалитьвремлемму", # основные "кутеуьздуььф", "elfkbnmdhtvktve", # раскладка - "rtl", "deltl", "удтемплемму", # сокращения + "rtl", "deltl", "удтемплемму", "rtlemma", "rtemplemma", "rtlem", ], # ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== - "remexcept": [ "remexcept", "удалитьисключение", # основные "кутучсузе", "elfkbnmbcrkx", # раскладка - "rxc", "remwhite", "удискл", # сокращения + "rxc", "remwhite", "удискл", ], # ==================== КОНФЛИКТНЫЕ СЛОВА ==================== - "addconflictword": [ "addconflictword", "добавитьконфликт", # основные "фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка - "acw", "addcw", "конфслово", # сокращения + "acw", "addcw", "конфслово", "conflictword", ], "addconflictlemma": [ "addconflictlemma", "добавитьконфлемму", # основные "фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка - "acl", "addcl", "конфлемму", # сокращения + "acl", "addcl", "конфлемму", "conflictlemma", ], "remconflictword": [ "remconflictword", "удалитьконфликт", # основные "кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка - "rcw", "delcw", "удконфликт", # сокращения + "rcw", "delcw", "удконфликт", ], "remconflictlemma": [ "remconflictlemma", "удалитьконфлемму", # основные - "кутсщтakшседуььф", "elfkbnmrjyaktve", # раскладка - "rcl", "delcl", "удконфлемму", # сокращения + "кутсщтакшседуььф", "elfkbnmrjyaktve", # раскладка + "rcl", "delcl", "удконфлемму", ], # ==================== РЕЖИМ АНТИКОНФЛИКТА ==================== - "stopconflict": [ "stopconflict", "стопконфликт", # основные - "cnjgsщтakшse", "cnjzrjyakbrn", # раскладка - "sconf", "sc", "стопконф", # сокращения + "cnjgsщтакшse", "cnjzrjyakbrn", # раскладка + "sconf", "sc", "стопконф", "stopconf", ], "unstopconflict": [ "unstopconflict", "отменаконфликта", # основные - "eycnjgsщтakшse", "jnvtyf", # раскладка - "usconf", "usc", "откконф", # сокращения + "eycnjgsщтакшse", "jnvtyf", # раскладка + "usconf", "usc", "откконф", "unstopconf", ], "conflictstatus": [ "conflictstatus", "статусконфликта", # основные - "сщтakшseыефnec", "cnfnec", # раскладка - "cstatus", "cs", "статконф", # сокращения + "сщтакшseыефnec", "cnfnec", # раскладка + "cstatus", "cs", "статконф", "confstat", ], # ==================== РЕЖИМ ТИШИНЫ ==================== - "silence": [ - "silence", "тишина", "мут", # основные - "ышдутсу", "nbibyf", "ven", # раскладка - "sil", "mute", "quiet", "тиш", # сокращения + "silence", "тишина", # основные + "ышдутсу", "nbibyf", # раскладка + "sl", "sil", "mute", "quiet", "тиш", "ven", ], "unsilence": [ "unsilence", "отменатишины", # основные - "eтышдутсу", "jnvtyf", # раскладка - "unsil", "unmute", "откмут", # сокращения + "eышдутсу", "jnvtyf", # раскладка + "unsil", "unmute", "откмут", "usl", "unsl", ], "silencestatus": [ "silencestatus", "статустишины", # основные - "ышдутсуыефnec", "cnfnec", # раскладка - "sstatus", "ss", "статтиш", # сокращения + "ышдутсуыефnec", "cnfnec", # раскладка + "sstatus", "ss", "статтиш", ], "extend_silence": [ "extend_silence", "продлитьтишину", # основные - "уче_ышдутсу", "ghjlkbnmnbibyet", # раскладка - "exsil", "exs", "продтиш", # сокращения + "ex_ышдутсу", "ghjlkbnmnbibyet", # раскладка + "exsil", "exs", "продтиш", ], # ==================== АДМИНИСТРАТОРЫ ==================== - "addadmin": [ "addadmin", "добавитьадмина", # основные "фввфвьшт", "lj,fdbnmflvbyf", # раскладка - "aa", "addadm", "добадм", # сокращения + "aa", "addadm", "добадм", ], "remadmin": [ "remadmin", "удалитьадмина", # основные "кутфвьшт", "elfkbnmflvbyf", # раскладка - "ra", "remadm", "deladmin", "удадм", # сокращения + "ra", "remadm", "deladmin", "удадм", ], "listadmins": [ "listadmins", "списокадминов", # основные "дшыефвьшты", "cgbcjrflvbyjd", # раскладка - "admins", "adm", "adminlist", "адм", # сокращения + "admins", "adm", "adminlist", "адм", "дшыефвь", "listadm", "la", ], "adminhelp": [ "adminhelp", "помощьадмину", # основные "фвьштрудз", "gjvjomflvbyt", # раскладка - "admhelp", "ah", "хелпадм", # сокращения + "admhelp", "ah", "хелпадм", ], "checkadmin": [ "checkadmin", "проверкаадмина", # основные "сруслфвьшт", "ghjdthrf", # раскладка - "isadmin", "ca", "провадм", # сокращения + "isadmin", "ca", "провадм", "checkadm", ], # ==================== ПРОСМОТР ==================== - "list": [ - "listwords", "списокслов", # основные + "listwords", "списокслов", "listword", # основные "дшыецщквы", "cgbcjrckjd", # раскладка - "lw", "list", "дшые", "words", "слова", # сокращения + "lw", "list", "дшые", "words", "слова", "l", ], "listlemmas": [ "listlemmas", "списоклемм", # основные "дшыедуььфы", "cgbcjrktv", # раскладка - "ll", "lemmas", "леммы", # сокращения + "ll", "lemmas", "леммы", ], "listparts": [ "listparts", "списокчастей", # основные "дшыезфкеы", "cgbcjrxfcntq", # раскладка - "lp", "parts", "части", # сокращения + "lp", "parts", "части", ], "listexcept": [ "listexcept", "списокисключений", # основные "дшыеучсузе", "cgbcjrbcrkx", # раскладка - "lxc", "except", "white", "искл", # сокращения + "lxc", "except", "white", "искл", ], "listconflict": [ "listconflict", "списокконфликтов", # основные - "дшыесщтakшse", "cgbcjrrjyakbrnjd", # раскладка - "lc", "conflict", "конф", # сокращения + "дшыесщтакшse", "cgbcjrrjyakbrnjd", # раскладка + "lc", "conflict", "конф", ], # ==================== СТАТИСТИКА ==================== - "userstats": [ "userstats", "статистикапользователя", # основные "ecthыефnы", "cnfnbcnbrf", # раскладка - "ustat", "us", "статюзер", # сокращения + "ustat", "us", "статюзер", ], "resetstats": [ "resetstats", "сброситьстат", # основные "кыуеыефnы", "c,hjcbnm", # раскладка - "rstats", "clearstats", "сброс", # сокращения + "rstats", "clearstats", "сброс", ], # ==================== ИНФОРМАЦИЯ ==================== - "id": [ "id", "айди", "инфо", # основные "шв", "fqlb", "byaj", # раскладка - "info", "me", "мои", # сокращения + "info", "me", "мои", ], "myid": [ "myid", "мойайди", # основные "ьншв", "vjqfqlb", # раскладка - "mid", "мид", # сокращения + "mid", "мид", ], "chatid": [ "chatid", "айдичата", # основные "срфешв", "fqlbxfnf", # раскладка - "cid", "чатид", # сокращения + "cid", "чатид", ], # ==================== РЕПОРТЫ ==================== - "report": [ "report", "репорт", "жалоба", # основные "кузщке", "htgjhn", ";fkj,f", # раскладка - "rep", "r", "жал", # сокращения + "rep", "r", "жал", ], "reporthelp": [ "reporthelp", "помощьрепорт", # основные "кузщкерудз", "gjvjomhtgjhn", # раскладка - "rephelp", "rh", "хелпреп", # сокращения + "rephelp", "rh", "хелпреп", ], "reportstats": [ "reportstats", "статистикарепортов", # основные "кузщкеыефnы", "cnfnbcnbrf", # раскладка - "rstat", "rs", "статреп", # сокращения + "rstat", "rs", "статреп", ], "checkreport": [ "checkreport", "проверкарепорта", # основные "сруслкузщке", "ghjdthrf", # раскладка - "crep", "cr", "провреп", # сокращения + "crep", "cr", "провреп", ], "closereport": [ "closereport", "закрытьрепорт", # основные "сдщыукузщке", "pfrhsnm", # раскладка - "close", "cl", "закреп", # сокращения + "close", "cl", "закреп", ], "banreport": [ "banreport", "забанитьрепорт", # основные "фтшкузщке", "pf,fybnm", # раскладка - "banrep", "br", "банреп", # сокращения + "banrep", "br", "банреп", ], # ==================== ЭМОДЗИ ==================== - "emoji": [ "emoji", "эмодзи", # основные "уьщош", "'vjlpb", # раскладка - "em", "emj", "эм", # сокращения + "em", "emj", "эм", ], "emojihelp": [ "emojihelp", "помощьэмодзи", # основные "уьщошрудз", "gjvjom'vjlpb", # раскладка - "emhelp", "emh", "хелпэм", # сокращения + "emhelp", "emh", "хелпэм", ], # ==================== СИСТЕМНЫЕ ==================== - "ping": [ "ping", "пинг", # основные "зштп", "gbyp", # раскладка - "p", "пн", # сокращения + "p", "пн", ], "version": [ "version", "версия", # основные "дукышщт", "dthcbz", # раскладка - "ver", "v", "вер", # сокращения + "ver", "v", ], "reload": [ "reload", "перезагрузка", # основные "кудщфв", "gthtpfuheprf", # раскладка - "rl", "restart", "рест", # сокращения + "rl", "restart", "рест", ], "logs": [ "logs", "логи", # основные "дщпы", "kjub", # раскладка - "log", "l", "лог", # сокращения + "log", "l", + ], + + "cancel": [ + "cancel", "c", # основные + "отменить", "сфтскд", # раскладка + ], + + "redactcomment": [ + "redactcomment", "editcomment", "комментарии", "redc", # основные + сокращения + "кувфсщтскщйьщк", "gfhthfyjdfz", # раскладка + "redcom", "editcom", "коммент", "rc", # дополнения ], -"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"], } diff --git a/configs/config.py b/configs/config.py index 34384af..77bb8a4 100644 --- a/configs/config.py +++ b/configs/config.py @@ -55,7 +55,6 @@ class _Settings(BaseSettings): # Идентификаторы OWNER_ID: list[int] = [6751720805] ADMIN_ID: list[int] = [] - ADMIN_CHAT_ID: int = 0 # Настройки бота BOT_NAME: str = "Бот" @@ -89,6 +88,24 @@ class _Settings(BaseSettings): description="URL фото по умолчанию" ) + # ================= АДМИНСКИЕ УВЕДОМЛЕНИЯ ================= + + # ID чата для уведомлений о банвордах/спаме + ADMIN_CHAT_ID: Optional[int] = -1002522785068 + + # ID топика для уведомлений о банвордах (опционально) + # Если None - уведомления идут в основной чат (General) + ADMIN_THREAD_ID: Optional[int] = None # Например: 12345 + + # ================= РЕПОРТЫ ================= + + # ID чата для репортов (если None, репорты идут владельцам в ЛС) + REPORT_CHAT_ID: Optional[int] = ADMIN_CHAT_ID # Можно тот же чат или другой + + # ID топика для репортов (опционально) + # Если None - репорты идут в основной чат (General) + REPORT_THREAD_ID: Optional[int] = None # ✅ ИСПРАВЛЕНО: было ADMIN_THREAD_ID + # Права администратора diff --git a/database/manager.py b/database/manager.py index b2b019b..7c1f09c 100644 --- a/database/manager.py +++ b/database/manager.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from middleware.loggers import logger from .database import Database, get_db from .repository import BanWordsRepository -from .models import BanWordType, SpamStat, SpamLog, TempBanWord +from .models import BanWordType, SpamStat, SpamLog, TempBanWord, AutoComment from sqlalchemy import select, delete, func, desc @@ -43,6 +43,7 @@ class BanWordsManager: async def init(self) -> None: """Инициализирует базу данных и загружает кэш""" await self.db.init() + await self.init_default_bot_settings() # ← добавлено await self.refresh_cache() logger.info("BanWordsManager инициализирован", log_type="DATABASE") @@ -335,7 +336,6 @@ class BanWordsManager: now = datetime.now().timestamp() if now >= silence_until: - # Время истекло - удаляем настройку await self.disable_silence_mode() return False @@ -381,7 +381,6 @@ class BanWordsManager: now = datetime.now().timestamp() if now >= conflict_until: - # Время истекло await self.disable_conflict_mode() return False @@ -433,7 +432,6 @@ class BanWordsManager: """Получает общую статистику""" db_stats = await self.repo.get_stats() - # Добавляем информацию о кэше cache_info = { 'cache_active': self._cache_banwords is not None, 'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None @@ -480,7 +478,6 @@ class BanWordsManager: """ async with self.db.get_session() as session: try: - # Группируем по matched_word и считаем количество query = select( SpamLog.matched_word, SpamLog.match_type, @@ -497,7 +494,6 @@ class BanWordsManager: result = await session.execute(query) rows = result.all() - # Форматируем результат top_words = [] for row in rows: top_words.append({ @@ -531,7 +527,6 @@ class BanWordsManager: try: now = datetime.now(timezone.utc) - # Ищем истёкшие временные слова query = select(TempBanWord).where( TempBanWord.expires_at < now ) @@ -541,23 +536,18 @@ class BanWordsManager: if not expired_words: return 0 - # Собираем информацию для логирования expired_info = [] for word in expired_words: expired_info.append({ 'word': word.word, - 'type': word.word_type.value, + 'type': word.type.value, # ← ИСПРАВЛЕНО: было word.word_type.value 'expires_at': word.expires_at }) await session.delete(word) - # Сохраняем изменения await session.commit() - - # Обновляем кеш await self.refresh_cache() - # Логируем подробности logger.info( f"Удалено {len(expired_words)} истёкших временных банвордов", log_type="DATABASE" @@ -608,7 +598,6 @@ class BanWordsManager: """ async with self.db.get_session() as session: try: - # Удаляем все записи await session.execute(delete(SpamLog)) await session.commit() @@ -629,32 +618,30 @@ class BanWordsManager: """ Получает настройки автокомментариев для канала. - Args: - channel_id: ID канала - - Returns: - dict: Настройки или значения по умолчанию + ВАЖНО: возвращает сохранённые поля даже когда is_enabled=False, + чтобы UI/preview показывали реальную конфигурацию. """ from configs import settings auto_comment = await self.repo.get_auto_comment(channel_id) - if auto_comment and auto_comment.is_enabled: - return { - 'text': auto_comment.text, - 'button_text': auto_comment.button_text, - 'button_url': auto_comment.button_url, - 'photo_url': auto_comment.photo_url, - 'is_enabled': auto_comment.is_enabled, - } - - # Возвращаем настройки по умолчанию из .env - return { + defaults = { 'text': settings.AUTO_COMMENT_TEXT, 'button_text': settings.AUTO_COMMENT_BUTTON_TEXT, 'button_url': settings.AUTO_COMMENT_BUTTON_URL, 'photo_url': settings.AUTO_COMMENT_PHOTO_URL, - 'is_enabled': False, # По умолчанию выключено + 'is_enabled': False, + } + + if not auto_comment: + return defaults + + return { + 'text': auto_comment.text if auto_comment.text is not None else defaults['text'], + 'button_text': auto_comment.button_text if auto_comment.button_text is not None else defaults['button_text'], + 'button_url': auto_comment.button_url if auto_comment.button_url is not None else defaults['button_url'], + 'photo_url': auto_comment.photo_url if auto_comment.photo_url is not None else defaults['photo_url'], + 'is_enabled': bool(auto_comment.is_enabled), } async def save_auto_comment_settings( @@ -715,6 +702,136 @@ class BanWordsManager: channel_id, 'photo_url', photo_url, updated_by ) + async def log_report( + self, + report_id: str, + reporter_id: int, + reporter_username: Optional[str], + reported_user_id: int, + reported_username: Optional[str], + chat_id: int, + chat_title: Optional[str], + message_id: int, + message_thread_id: Optional[int], + message_text: Optional[str], + reason: str + ) -> bool: + """Логирует репорт в БД""" + return await self.repo.log_report( + report_id=report_id, + reporter_id=reporter_id, + reporter_username=reporter_username, + reported_user_id=reported_user_id, + reported_username=reported_username, + chat_id=chat_id, + chat_title=chat_title, + message_id=message_id, + message_thread_id=message_thread_id, + message_text=message_text, + reason=reason + ) + + # ==================== ✅ КАНАЛЫ АВТОКОММЕНТАРИЕВ ==================== + + async def add_auto_comment_channel(self, channel_id: int, added_by: int) -> bool: + """✅ Добавляет новый канал в БД""" + async with self.db.get_session() as session: + try: + new_channel = AutoComment( + channel_id=channel_id, + text="", + button_text="", + button_url="", + photo_url="", + is_enabled=False, + updated_by=added_by, + ) + session.add(new_channel) + await session.commit() + await session.refresh(new_channel) + logger.info(f"✅ Канал добавлен: {channel_id}", log_type="CHANNEL") + return True + except Exception as e: + await session.rollback() + logger.error(f"Ошибка добавления канала {channel_id}: {e}", log_type="CHANNEL") + return False + + async def get_auto_comment_channels(self) -> List[int]: + """✅ Возвращает все channel_id из БД""" + async with self.db.get_session() as session: + result = await session.execute(select(AutoComment.channel_id).distinct()) + return [row[0] for row in result.fetchall()] + + async def delete_auto_comment(self, channel_id: int) -> bool: + """✅ Удаляет настройки канала""" + async with self.db.get_session() as session: + try: + result = await session.execute( + delete(AutoComment).where(AutoComment.channel_id == channel_id) + ) + if result.rowcount > 0: + await session.commit() + logger.info(f"✅ Канал удален: {channel_id}", log_type="CHANNEL") + return True + await session.rollback() + return False + except Exception as e: + await session.rollback() + logger.error(f"Ошибка удаления канала {channel_id}: {e}", log_type="CHANNEL") + return False + + # ==================== BOT SETTINGS (замена .env) ==================== + async def get_bot_settings(self) -> dict: + """Получает все настройки бота из БД""" + settings = { + 'admin_chat_id': await self.repo.get_setting("admin_chat_id"), + 'admin_thread_id': await self.repo.get_setting("admin_thread_id"), + 'report_chat_id': await self.repo.get_setting("report_chat_id"), + 'report_thread_id': await self.repo.get_setting("report_thread_id"), + } + return {k: v for k, v in settings.items() if v is not None} + + async def set_bot_setting(self, key: str, value: Optional[str]) -> bool: + """ + Сохраняет настройку бота в БД + + Args: + key: admin_chat_id, admin_thread_id, report_chat_id, report_thread_id + value: str или None/null + + Returns: + bool: True если сохранено + """ + if value is None: + return await self.repo.delete_setting(key) + else: + return await self.repo.set_setting(key, value) + + async def get_bot_setting(self, key: str) -> Optional[str]: + """Получает ОДНУ настройку бота""" + settings = await self.get_bot_settings() + return settings.get(key) + + async def init_default_bot_settings(self) -> None: + """Инициализирует настройки по умолчанию из .env""" + try: + from configs import settings + + defaults = { + "admin_chat_id": getattr(settings, 'ADMIN_CHAT_ID', None), + "admin_thread_id": str(getattr(settings, 'ADMIN_THREAD_ID', None)) if getattr(settings, 'ADMIN_THREAD_ID', None) else None, + "report_chat_id": getattr(settings, 'REPORT_CHAT_ID', None), + "report_thread_id": str(getattr(settings, 'REPORT_THREAD_ID', None)) if getattr(settings, 'REPORT_THREAD_ID', None) else None, + } + + for key, value in defaults.items(): + if value: # Не null + await self.set_bot_setting(key, str(value)) + + logger.info("✅ Настройки бота инициализированы из .env", log_type="SETTINGS") + except Exception as e: + logger.warning(f"Не удалось инициализировать настройки из .env: {e}", log_type="SETTINGS") + # Глобальный экземпляр менеджера _manager_instance: Optional[BanWordsManager] = None diff --git a/database/models.py b/database/models.py index 8bf4acc..c56bab3 100644 --- a/database/models.py +++ b/database/models.py @@ -19,7 +19,8 @@ __all__ = ( "Setting", "SpamStat", "SpamLog", -"AutoComment", + "AutoComment", + "Report", ) @@ -294,3 +295,68 @@ class AutoComment(Base): def __repr__(self) -> str: return f"" + + +class Report(Base): + """ + Модель для хранения статистики репортов. + + Attributes: + id: Уникальный ID репорта + report_id: Строковый ID репорта (timestamp) + reporter_id: ID пользователя, который пожаловался + reporter_username: Username жалобщика + reported_user_id: ID пользователя, на которого пожаловались + reported_username: Username нарушителя + chat_id: ID чата, где произошло нарушение + chat_title: Название чата + message_id: ID сообщения-нарушения + message_thread_id: ID топика (если есть) + message_text: Текст сообщения (до 500 символов) + reason: Причина жалобы + status: Статус репорта (pending, closed, banned, deleted) + processed_by: ID админа, который обработал + created_at: Дата создания репорта + processed_at: Дата обработки + """ + __tablename__ = "reports" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + report_id: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True) + + # Информация о жалобщике + reporter_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + reporter_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + + # Информация о нарушителе + reported_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + reported_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + + # Информация о чате и сообщении + chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + chat_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + message_id: Mapped[int] = mapped_column(Integer, nullable=False) + message_thread_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + message_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Причина и статус + reason: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + String(20), + default="pending", + nullable=False, + index=True + ) # pending, closed, banned, deleted + + # Обработка + processed_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False, + index=True + ) + processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/database/repository.py b/database/repository.py index 425711e..51fa523 100644 --- a/database/repository.py +++ b/database/repository.py @@ -157,12 +157,6 @@ class BanWordsRepository: return set() async def get_all_banwords(self) -> dict[BanWordType, Set[str]]: - """ - Получает все банворды, сгруппированные по типам. - - Returns: - dict: {BanWordType: Set[str]} - """ result = { BanWordType.SUBSTRING: set(), BanWordType.LEMMA: set(), @@ -170,19 +164,28 @@ class BanWordsRepository: BanWordType.CONFLICT_SUBSTRING: set(), BanWordType.CONFLICT_LEMMA: set(), } - try: async with self.db.get_session() as session: banwords = await session.execute(select(BanWord)) + loaded = 0 for banword in banwords.scalars(): - result[banword.type].add(banword.word) - + try: + word_type = ( + banword.type + if isinstance(banword.type, BanWordType) + else BanWordType(banword.type.casefold()) + ) + if word_type in result: + result[word_type].add(banword.word) + loaded += 1 + except ValueError: + logger.warning( + f"Неизвестный тип: '{banword.type}' для '{banword.word}'", + log_type="DATABASE" + ) + logger.info(f"✅ Кэш загружен: {loaded} банвордов", log_type="DATABASE") except Exception as e: - logger.error( - f"Ошибка получения всех банвордов: {e}", - log_type="DATABASE" - ) - + logger.error(f"❌ get_all_banwords: {e}", log_type="DATABASE") return result async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]: @@ -344,8 +347,14 @@ class BanWordsRepository: ) ) for temp_banword in temp_banwords.scalars(): - if temp_banword.type in result: - result[temp_banword.type].add(temp_banword.word) + word_type = ( + temp_banword.type + if isinstance(temp_banword.type, BanWordType) + else BanWordType(temp_banword.type.casefold()) + ) + if word_type in result: + result[word_type].add(temp_banword.word) + except Exception as e: logger.error( @@ -1046,3 +1055,336 @@ class BanWordsRepository: log_type="DATABASE" ) return False + + # === REPORTS === + + async def log_report( + self, + report_id: str, + reporter_id: int, + reporter_username: Optional[str], + reported_user_id: int, + reported_username: Optional[str], + chat_id: int, + chat_title: Optional[str], + message_id: int, + message_thread_id: Optional[int], + message_text: Optional[str], + reason: str + ) -> bool: + """ + Сохраняет репорт в БД. + + Args: + report_id: Уникальный ID репорта + reporter_id: ID жалобщика + reporter_username: Username жалобщика + reported_user_id: ID нарушителя + reported_username: Username нарушителя + chat_id: ID чата + chat_title: Название чата + message_id: ID сообщения + message_thread_id: ID топика + message_text: Текст сообщения + reason: Причина жалобы + + Returns: + bool: True если успешно + """ + try: + from .models import Report # Импорт здесь, чтобы избежать циклических импортов + + async with self.db.get_session() as session: + report = Report( + report_id=report_id, + reporter_id=reporter_id, + reporter_username=reporter_username, + reported_user_id=reported_user_id, + reported_username=reported_username, + chat_id=chat_id, + chat_title=chat_title, + message_id=message_id, + message_thread_id=message_thread_id, + message_text=message_text[:500] if message_text else None, + reason=reason + ) + session.add(report) + await session.commit() + + logger.info( + f"Репорт #{report_id} сохранён в БД", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка сохранения репорта: {e}", + log_type="DATABASE" + ) + return False + + async def update_report_status( + self, + report_id: str, + status: str, + processed_by: int + ) -> bool: + """ + Обновляет статус репорта. + + Args: + report_id: ID репорта + status: Новый статус (closed, banned, deleted) + processed_by: ID админа + + Returns: + bool: True если успешно + """ + try: + from .models import Report + + async with self.db.get_session() as session: + result = await session.execute( + select(Report).where(Report.report_id == report_id) + ) + report = result.scalar_one_or_none() + + if not report: + return False + + report.status = status + report.processed_by = processed_by + report.processed_at = datetime.now(timezone.utc) + await session.commit() + + logger.info( + f"Репорт #{report_id} обновлён: статус={status}", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка обновления статуса репорта: {e}", + log_type="DATABASE" + ) + return False + + async def get_report_stats(self) -> dict: + """ + Получает общую статистику по репортам. + + Returns: + dict: Статистика + """ + try: + from .models import Report + + async with self.db.get_session() as session: + # Всего репортов + total_reports = await session.execute( + select(func.count(Report.id)) + ) + + # По статусам + pending_reports = await session.execute( + select(func.count(Report.id)).where(Report.status == "pending") + ) + closed_reports = await session.execute( + select(func.count(Report.id)).where(Report.status == "closed") + ) + banned_reports = await session.execute( + select(func.count(Report.id)).where(Report.status == "banned") + ) + deleted_reports = await session.execute( + select(func.count(Report.id)).where(Report.status == "deleted") + ) + + return { + 'total': total_reports.scalar_one(), + 'pending': pending_reports.scalar_one(), + 'closed': closed_reports.scalar_one(), + 'banned': banned_reports.scalar_one(), + 'deleted': deleted_reports.scalar_one(), + } + + except Exception as e: + logger.error( + f"Ошибка получения статистики репортов: {e}", + log_type="DATABASE" + ) + return {} + + async def get_top_reporters(self, limit: int = 10) -> List[tuple[int, str, int]]: + """ + Получает топ жалобщиков. + + Args: + limit: Количество записей + + Returns: + List[tuple[int, str, int]]: [(user_id, username, count), ...] + """ + try: + from .models import Report + + async with self.db.get_session() as session: + result = await session.execute( + select( + Report.reporter_id, + Report.reporter_username, + func.count(Report.id).label('count') + ) + .group_by(Report.reporter_id, Report.reporter_username) + .order_by(func.count(Report.id).desc()) + .limit(limit) + ) + return [ + (row.reporter_id, row.reporter_username or f"id{row.reporter_id}", row.count) + for row in result + ] + + except Exception as e: + logger.error( + f"Ошибка получения топ жалобщиков: {e}", + log_type="DATABASE" + ) + return [] + + async def get_top_reported_users(self, limit: int = 10) -> List[tuple[int, str, int]]: + """ + Получает топ нарушителей. + + Args: + limit: Количество записей + + Returns: + List[tuple[int, str, int]]: [(user_id, username, count), ...] + """ + try: + from .models import Report + + async with self.db.get_session() as session: + result = await session.execute( + select( + Report.reported_user_id, + Report.reported_username, + func.count(Report.id).label('count') + ) + .group_by(Report.reported_user_id, Report.reported_username) + .order_by(func.count(Report.id).desc()) + .limit(limit) + ) + return [ + (row.reported_user_id, row.reported_username or f"id{row.reported_user_id}", row.count) + for row in result + ] + + except Exception as e: + logger.error( + f"Ошибка получения топ нарушителей: {e}", + log_type="DATABASE" + ) + return [] + + async def get_recent_reports(self, limit: int = 20) -> List: + """ + Получает последние репорты. + + Args: + limit: Количество записей + + Returns: + List[Report]: Список репортов + """ + try: + from .models import Report + + async with self.db.get_session() as session: + result = await session.execute( + select(Report) + .order_by(Report.created_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + except Exception as e: + logger.error( + f"Ошибка получения последних репортов: {e}", + log_type="DATABASE" + ) + return [] + + async def get_user_report_count(self, user_id: int, as_reporter: bool = True) -> int: + """ + Получает количество репортов пользователя. + + Args: + user_id: ID пользователя + as_reporter: True - как жалобщик, False - как нарушитель + + Returns: + int: Количество репортов + """ + try: + from .models import Report + + async with self.db.get_session() as session: + if as_reporter: + result = await session.execute( + select(func.count(Report.id)).where( + Report.reporter_id == user_id + ) + ) + else: + result = await session.execute( + select(func.count(Report.id)).where( + Report.reported_user_id == user_id + ) + ) + return result.scalar_one() + + except Exception as e: + logger.error( + f"Ошибка подсчёта репортов пользователя: {e}", + log_type="DATABASE" + ) + return 0 + + + async def get_setting(self, key: str) -> Optional[str]: + """Получает значение настройки""" + async with self.db.get_session() as session: + result = await session.get(Setting, key) + return result.value if result else None + + async def set_setting(self, key: str, value: str) -> bool: + """Устанавливает значение настройки""" + async with self.db.get_session() as session: + try: + setting = await session.get(Setting, key) + if setting: + setting.value = value + setting.updated_at = datetime.now() + else: + setting = Setting(key=key, value=value) + session.add(setting) + await session.commit() + return True + except Exception as e: + await session.rollback() + logger.error(f"set_setting {key} failed: {e}", log_type="DATABASE") + return False + + async def delete_setting(self, key: str) -> bool: + """Удаляет настройку""" + async with self.db.get_session() as session: + try: + result = await session.execute(delete(Setting).where(Setting.key == key)) + await session.commit() + return result.rowcount > 0 + except Exception as e: + await session.rollback() + logger.error(f"delete_setting {key} failed: {e}", log_type="DATABASE") + return False