From 5d350d08853bbedde566fe9294d7987a5f9ef0b5 Mon Sep 17 00:00:00 2001 From: Whyverum Date: Wed, 18 Feb 2026 01:43:22 +0700 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=81=D1=82=D0=B0=D1=82=20=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B2=D0=BE=D0=B7=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D1=88=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env_example | 10 + Dockerfile | 1 + bot/core/bots.py | 12 +- bot/handlers/__init__.py | 2 + bot/handlers/chl_comment.py | 770 ++++++++++++++++++++++++++++ bot/handlers/commands/users/word.py | 68 ++- bot/middlewares/__init__.py | 5 + bot/middlewares/banwords_mdw.py | 98 +++- bot/middlewares/spam_mdw.py | 261 +++++----- configs/cmd_alias_list.py | 1 + configs/config.py | 51 +- database/database.py | 5 +- database/manager.py | 92 ++++ database/models.py | 42 ++ database/repository.py | 254 ++++++++- 15 files changed, 1489 insertions(+), 183 deletions(-) create mode 100644 bot/handlers/chl_comment.py diff --git a/.env_example b/.env_example index f3237de..b8ce0ae 100644 --- a/.env_example +++ b/.env_example @@ -257,3 +257,13 @@ KEEP_BACKUPS=7 # Возраст старой статистики для удаления (дни) STATS_MAX_AGE_DAYS=30 + +# ============ АВТОКОММЕНТАРИИ ============ +# ID каналов через запятую (узнать через @userinfobot) +AUTO_COMMENT_CHANNELS=-1001234567890 + +# Настройки по умолчанию (будут использоваться для новых каналов) +AUTO_COMMENT_TEXT=🔍 Нужна помощь?\n\nИспользуй наш сервис! +AUTO_COMMENT_BUTTON_TEXT=🌐 Искать в Google +AUTO_COMMENT_BUTTON_URL=https://www.google.com +AUTO_COMMENT_PHOTO_URL=https://via.placeholder.com/800x600.png \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 89e5c61..1ef6fb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ RUN pip install --no-cache-dir -r requirements.txt # Копируем все файлы проекта COPY . . +RUN mkdir -p /app/data && chmod -R 777 /app/data # Запускаем бота CMD ["python", "main.py"] diff --git a/bot/core/bots.py b/bot/core/bots.py index 4090df2..00f037e 100644 --- a/bot/core/bots.py +++ b/bot/core/bots.py @@ -123,8 +123,18 @@ class BotInfo: # Устанавливаем webhook await bots.set_webhook( url=settings.WEBHOOK_URL, + allowed_updates=[ + "message", + "edited_message", + "channel_post", # ← ВОТ ЭТО ДОБАВЬ! + "edited_channel_post", # ← И ЭТО + "callback_query", + "inline_query", + "my_chat_member", + "chat_member" + ], secret_token=settings.SECRET_TOKEN, - drop_pending_updates=True + drop_pending_updates=True, ) logger.success( diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index ea860be..8a43f06 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -2,6 +2,7 @@ from aiogram import Router from .commands import router as cmd_routers from .messages import router as messages_routers +from .chl_comment import router as channels_routers # Настройка экспорта и роутера __all__ = ("router",) @@ -9,6 +10,7 @@ router: Router = Router(name=__name__) # Подключение роутеров router.include_routers( +channels_routers, cmd_routers, messages_routers, ) diff --git a/bot/handlers/chl_comment.py b/bot/handlers/chl_comment.py new file mode 100644 index 0000000..397a471 --- /dev/null +++ b/bot/handlers/chl_comment.py @@ -0,0 +1,770 @@ +""" +Автоматическая отправка комментариев под постами канала (через discussion group) ++ меню настройки (FSM) ++ полная диагностика + +ВАЖНО: +- Комментарии в Telegram — это reply в привязанной группе обсуждений. +- Поэтому ловим auto-forward сообщения в группе: Message.is_automatic_forward == True. +""" + +from __future__ import annotations + +import time +from typing import Optional, Tuple, Dict + +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 +from database import get_manager +from middleware.loggers import logger +from bot.filters.admin import IsAdmin + +__all__ = ("router",) + +router: Router = Router(name="channel_comments_router") + +# ====================================================================== +# FSM STATES +# ====================================================================== + +class CommentEditStates(StatesGroup): + """Состояния для редактирования комментариев""" + selecting_channel = State() + waiting_text = State() + waiting_button_text = State() + waiting_button_url = State() + waiting_photo_url = State() + + +# ====================================================================== +# HELPERS +# ====================================================================== + +def _defaults() -> dict: + return { + "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, + } + + +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 + + +def create_main_menu(channel_id: int) -> InlineKeyboardBuilder: + """Создаёт главное меню управления автокомментариями""" + ikb = InlineKeyboardBuilder() + ikb.button(text="📝 Текст комментария", callback_data=f"edit:{channel_id}:text") + ikb.button(text="🔘 Кнопка", callback_data=f"edit:{channel_id}:button") + ikb.button(text="🖼 Фото (скрытое)", callback_data=f"edit:{channel_id}:photo") + 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="menu:close") + ikb.adjust(2, 2, 2, 1, 1) + return ikb + + +def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder: + """Создаёт меню выбора канала""" + 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="menu:close") + ikb.adjust(1) + return ikb + + +def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]: + full_text = hide_link(config["photo_url"]) + (config["text"] or "") + 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_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 + + now = time.time() + 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()): + if t < cutoff: + _MEDIA_GROUP_SEEN.pop(k, None) + + if last and (now - last) < _MEDIA_GROUP_TTL_SEC: + return True + + _MEDIA_GROUP_SEEN[key] = now + return False + + +# ====================================================================== +# CORE: AUTO COMMENTS (discussion group) +# ====================================================================== + +@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}", + log_type="CHANNEL" + ) + return + + logger.info( + f"📥 Discussion forward received: chat={message.chat.id}, msg_id={message.message_id}, " + f"is_auto={message.is_automatic_forward}, forward_from_chat={getattr(message.forward_from_chat, 'id', None)}", + log_type="CHANNEL" + ) + + # 1) Канал-источник + channel_id = _extract_origin_channel_id(message) + if not channel_id: + logger.warning( + f"❌ Cannot extract origin channel id for msg={message.message_id} in chat={message.chat.id}", + log_type="CHANNEL" + ) + return + + # 2) Проверка списка каналов + channels = settings.AUTO_COMMENT_CHANNELS_LIST + 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: + logger.error(f"❌ Config load failed for channel={channel_id}: {e}", log_type="CHANNEL") + return + + if not config.get("is_enabled") and not is_test: + 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" + ) + + logger.success( + "✅ 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" └─ Test mode: {is_test}", + log_type="CHANNEL" + ) + + except TelegramBadRequest as e: + logger.error( + f"❌ TelegramBadRequest while sending comment: {e}\n" + f" channel={channel_id} discussion_chat={message.chat.id} msg={message.message_id}", + log_type="CHANNEL" + ) + except TelegramForbiddenError as e: + logger.error( + f"❌ TelegramForbiddenError while sending comment: {e}\n" + f" Bot likely has no rights to write in discussion group.\n" + f" channel={channel_id} discussion_chat={message.chat.id}", + log_type="CHANNEL" + ) + except Exception as e: + logger.error( + f"❌ Unexpected error while sending comment: {e}", + log_type="CHANNEL", + ) + + +# ====================================================================== +# DIAGNOSTICS +# ====================================================================== + +@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 + + await callback.answer("🔍 Запуск диагностики...", show_alert=False) + + diagnostic_text = "🔍 ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ\n\n" + + # 1) ENV settings + channels = settings.AUTO_COMMENT_CHANNELS_LIST + diagnostic_text += "1️⃣ Настройки:\n" + diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: {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) + diagnostic_text += " ├─ Настройки читаются: ✅\n" + diagnostic_text += f" ├─ Статус: {'✅ Включено' if config.get('is_enabled') else '❌ Выключено'}\n" + diagnostic_text += f" ├─ Текст: {len(config.get('text') or '')} символов\n" + diagnostic_text += f" ├─ Кнопка: {('✅' if (config.get('button_text') and config.get('button_url')) else '❌')}\n" + diagnostic_text += f" └─ Фото URL: {('✅' if bool(config.get('photo_url')) else '❌')}\n\n" + except Exception as e: + 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) + diagnostic_text += f" ├─ Статус: {member.status}\n" + if member.status == "administrator": + diagnostic_text += " ├─ Админ: ✅\n" + if hasattr(member, "can_post_messages"): + diagnostic_text += f" └─ can_post_messages: {'✅' if member.can_post_messages else '❌'}\n" + else: + diagnostic_text += " └─ can_post_messages: (нет поля у этого типа)\n" + elif member.status == "creator": + diagnostic_text += " └─ Создатель: ✅\n" + else: + diagnostic_text += " └─ НЕ админ: ❌\n" + diagnostic_text += "\n" + except TelegramForbiddenError: + diagnostic_text += " └─ ❌ Бот не в канале / нет доступа\n\n" + except Exception as e: + diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" + + # 4) Linked discussion group + diagnostic_text += "4️⃣ Привязанная группа обсуждений:\n" + linked_chat_id = None + try: + chat = await bot.get_chat(channel_id) + linked_chat_id = getattr(chat, "linked_chat_id", None) + if linked_chat_id: + diagnostic_text += " ├─ linked_chat_id: ✅\n" + diagnostic_text += f" └─ ID: {linked_chat_id}\n\n" + else: + diagnostic_text += " └─ ❌ Не подключена (linked_chat_id отсутствует)\n\n" + 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" + else: + try: + gmember = await bot.get_chat_member(linked_chat_id, bot.id) + diagnostic_text += f" ├─ Статус: {gmember.status}\n" + if gmember.status in ("administrator", "creator", "member"): + diagnostic_text += " ├─ Присутствует: ✅\n" + 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: + diagnostic_text += " └─ can_send_messages: (нет поля у этого типа)\n\n" + except TelegramForbiddenError: + diagnostic_text += " └─ ❌ Бот не в группе / нет доступа\n\n" + 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") + + +# ====================================================================== +# ADMIN UI: COMMAND + MENUS +# ====================================================================== + +@router.message(Command("redactcomment"), IsAdmin()) +async def redact_comment_cmd(message: Message, state: FSMContext) -> None: + """Открывает меню управления автокомментариями""" + channels = settings.AUTO_COMMENT_CHANNELS_LIST + + if not channels: + await message.answer( + "❌ Каналы не настроены\n\n" + "Добавьте ID каналов в .env файл:\n" + "AUTO_COMMENT_CHANNELS=-1003876862007\n\n" + "💡 Узнать ID канала: перешлите пост из канала боту @userinfobot", + parse_mode="HTML" + ) + return + + if len(channels) == 1: + 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"💡 Выберите действие:" + ) + + await message.answer( + text=output, + reply_markup=create_main_menu(channel_id).as_markup(), + parse_mode="HTML" + ) + + +@router.callback_query(F.data.startswith("select_channel:")) +async def select_channel_callback(callback: CallbackQuery) -> None: + """Обработка выбора канала из списка""" + channel_id = int(callback.data.split(":")[1]) + 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" + ) + await callback.answer() + + +# ====================================================================== +# EDIT TEXT +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):text")) +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" + ) + await callback.answer() + + +@router.message(CommentEditStates.waiting_text) +async def process_text_input(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + data = await state.get_data() + channel_id = data.get("channel_id") + + manager = get_manager() + success = await manager.update_auto_comment_text( + channel_id=channel_id, + text=message.text or "", + updated_by=message.from_user.id + ) + + await state.clear() + + if not success: + await message.answer( + "❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", + parse_mode="HTML" + ) + return + + await message.answer(f"✅ Текст обновлён!", parse_mode="HTML") + await show_channel_menu(message, channel_id) + + +# ====================================================================== +# EDIT BUTTON +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):button")) +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" + ) + await callback.answer() + + +@router.message(CommentEditStates.waiting_button_text) +async def process_button_text(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + await state.update_data(button_text=message.text or "") + await state.set_state(CommentEditStates.waiting_button_url) + + await message.answer( + text=( + f"✅ Текст кнопки: {message.text}\n\n" + f"Шаг 2 из 2: Отправьте URL кнопки\n\n" + f"Для отмены: /cancel" + ), + parse_mode="HTML" + ) + + +@router.message(CommentEditStates.waiting_button_url) +async def process_button_url(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + url = message.text or "" + if not url.startswith(("http://", "https://")): + await message.answer( + "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", + parse_mode="HTML" + ) + return + + data = await state.get_data() + channel_id = data.get("channel_id") + button_text = data.get("button_text") or "" + + 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 + ) + + await state.clear() + + if not success: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + return + + await message.answer("✅ Кнопка обновлена!", parse_mode="HTML") + await show_channel_menu(message, channel_id) + + +# ====================================================================== +# EDIT PHOTO URL +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo")) +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" + ) + await callback.answer() + + +@router.message(CommentEditStates.waiting_photo_url) +async def process_photo_url(message: Message, state: FSMContext) -> None: + if message.text == "/cancel": + await state.clear() + await message.answer("❌ Отменено") + return + + url = message.text or "" + if not url.startswith(("http://", "https://")): + await message.answer( + "❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://", + parse_mode="HTML" + ) + return + + data = await state.get_data() + channel_id = data.get("channel_id") + + manager = get_manager() + success = await manager.update_auto_comment_photo( + channel_id=channel_id, + photo_url=url, + updated_by=message.from_user.id + ) + + await state.clear() + + if not success: + await message.answer("❌ Ошибка сохранения", parse_mode="HTML") + return + + await message.answer(hide_link(url) + "✅ Фото обновлено!", parse_mode="HTML") + await show_channel_menu(message, channel_id) + + +# ====================================================================== +# PREVIEW +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview")) +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" + ) + await callback.answer("✅ Превью отправлено") + + +# ====================================================================== +# TOGGLE +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle")) +async def toggle_comment_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + + config = await get_channel_config(channel_id) + current_status = bool(config.get("is_enabled")) + new_status = not current_status + + manager = get_manager() + + if new_status: + success = await manager.save_auto_comment_settings( + channel_id=channel_id, + text=config.get("text") or "", + button_text=config.get("button_text") or "", + button_url=config.get("button_url") or "", + photo_url=config.get("photo_url") or "", + updated_by=callback.from_user.id + ) + else: + success = await manager.repo.toggle_auto_comment( + channel_id=channel_id, + is_enabled=False, + updated_by=callback.from_user.id + ) + + if not success: + await callback.answer("❌ Ошибка переключения", show_alert=True) + return + + 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" + ) + + +# ====================================================================== +# DELETE SETTINGS +# ====================================================================== + +@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete")) +async def delete_comment_callback(callback: CallbackQuery) -> None: + channel_id = int(callback.data.split(":")[1]) + + manager = get_manager() + success = await manager.repo.delete_auto_comment(channel_id) + + if not success: + await callback.answer("❌ Ошибка удаления", show_alert=True) + 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" + ) + + +# ====================================================================== +# 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/word.py b/bot/handlers/commands/users/word.py index 78083ae..5a5d82a 100644 --- a/bot/handlers/commands/users/word.py +++ b/bot/handlers/commands/users/word.py @@ -1,7 +1,7 @@ """ Обработчики команд добавления и удаления банвордов """ -from aiogram import Router, F +from aiogram import Router from aiogram.filters import Command from aiogram.types import Message @@ -36,7 +36,8 @@ def parse_args(text: str, command: str, min_args: int = 1, max_args: int = 2) -> parts = text.split(maxsplit=max_args) if len(parts) < min_args + 1: - return False, f"❌ Использование: /{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}" + usage = f"/{command} <слово>" if min_args == 1 else f"/{command} <слово> <минуты>" + return False, f"❌ Использование: {usage}" args = parts[1:] @@ -71,6 +72,20 @@ def format_success_message(action: str, word: str, word_type: str, extra: str = return message +def format_time(minutes: int) -> str: + """Форматирует время в читаемый вид""" + if minutes < 60: + return f"{minutes} мин" + elif minutes < 1440: + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}ч {mins}м" if mins else f"{hours}ч" + else: + days = minutes // 1440 + hours = (minutes % 1440) // 60 + return f"{days}д {hours}ч" if hours else f"{days}д" + + # ================= КОМАНДЫ ДОБАВЛЕНИЯ ================= @router.message(Command(*COMMANDS.get("addword", ["addword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin()) @@ -111,7 +126,7 @@ async def add_word_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD") + logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -153,7 +168,7 @@ async def add_lemma_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD") + logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -195,7 +210,7 @@ async def add_part_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления части: {e}", log_type="CMD") + logger.error(f"Ошибка добавления части: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -237,18 +252,7 @@ async def add_temp_word_cmd(message: Message) -> None: ) if added: - # Форматируем время - if minutes < 60: - time_str = f"{minutes} мин" - elif minutes < 1440: - hours = minutes // 60 - mins = minutes % 60 - time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч" - else: - days = minutes // 1440 - hours = (minutes % 1440) // 60 - time_str = f"{days}д {hours}ч" if hours else f"{days}д" - + time_str = format_time(minutes) text = format_success_message( "добавлена", word, @@ -261,7 +265,7 @@ async def add_temp_word_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD") + logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -302,17 +306,7 @@ async def add_temp_lemma_cmd(message: Message) -> None: ) if added: - if minutes < 60: - time_str = f"{minutes} мин" - elif minutes < 1440: - hours = minutes // 60 - mins = minutes % 60 - time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч" - else: - days = minutes // 1440 - hours = (minutes % 1440) // 60 - time_str = f"{days}д {hours}ч" if hours else f"{days}д" - + time_str = format_time(minutes) text = format_success_message( "добавлена", word, @@ -325,7 +319,7 @@ async def add_temp_lemma_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD") + logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -366,7 +360,7 @@ async def add_exception_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD") + logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML") @@ -400,7 +394,7 @@ async def remove_word_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD") + logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") @@ -428,7 +422,7 @@ async def remove_lemma_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD") + logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") @@ -456,7 +450,7 @@ async def remove_part_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления части: {e}", log_type="CMD") + logger.error(f"Ошибка удаления части: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") @@ -485,7 +479,7 @@ async def remove_temp_word_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD") + logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") @@ -514,7 +508,7 @@ async def remove_temp_lemma_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD") + logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") @@ -542,5 +536,5 @@ async def remove_exception_cmd(message: Message) -> None: await message.answer(text, parse_mode="HTML") except Exception as e: - logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD") + logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD", exc_info=True) await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML") diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py index 0a3d294..c1db9b0 100644 --- a/bot/middlewares/__init__.py +++ b/bot/middlewares/__init__.py @@ -124,6 +124,9 @@ def setup_middlewares( if enable_subscription_check: enabled_features.append("Subscription") + dp.channel_post.middleware(LoggingMiddleware()) + dp.edited_channel_post.middleware(LoggingMiddleware()) + logger.info( text=( f"Middleware зарегистрированы: " @@ -135,3 +138,5 @@ def setup_middlewares( ) return instances + + diff --git a/bot/middlewares/banwords_mdw.py b/bot/middlewares/banwords_mdw.py index 7f0d327..38225aa 100644 --- a/bot/middlewares/banwords_mdw.py +++ b/bot/middlewares/banwords_mdw.py @@ -9,6 +9,8 @@ Pipeline проверки: 5. Проверяем постоянные банворды (substring, lemma, part) 6. Проверяем временные банворды 7. Если найдено - удаляем, логируем, уведомляем админов + +НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1). """ from typing import Callable, Dict, Any, Awaitable, Optional import re @@ -103,9 +105,36 @@ class BanWordsMiddleware(BaseMiddleware): "hello@world.com" -> "helloworldcom" "test_123-456" -> "test123456" """ - # Оставляем только буквы и цифры return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower()) + @staticmethod + def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str: + """ + Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог"). + + 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) + async def _check_message(self, text: str) -> Optional[Dict[str, str]]: """ Проверяет сообщение на наличие банвордов. @@ -120,10 +149,20 @@ class BanWordsMiddleware(BaseMiddleware): text_lower = text.lower() 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]}'", + log_type="BANWORDS" + ) + # === 1. WHITELIST (исключения) === - if self.manager.is_whitelisted(text_processed): + # Проверяем оба варианта: с повторами и без + if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized): logger.debug( - f"Сообщение содержит whitelist слово: '{text_processed[:50]}'", + f"Сообщение содержит whitelist слово", log_type="BANWORDS" ) return None @@ -137,12 +176,13 @@ class BanWordsMiddleware(BaseMiddleware): # === 3. CONFLICT MODE (конфликтные слова) === if await self.manager.is_conflict_active(): - # Проверяем конфликтные подстроки + # Проверяем конфликтные подстроки (с нормализацией) conflict_substring = self.manager.get_banwords_cached( BanWordType.CONFLICT_SUBSTRING ) for word in conflict_substring: - if word in text_processed: + word_normalized = self._normalize_repeated_chars(word, max_repeats=1) + if word_normalized in text_normalized: return {"word": word, "type": "conflict_substring"} # Проверяем конфликтные леммы @@ -151,35 +191,40 @@ class BanWordsMiddleware(BaseMiddleware): ) words_in_text = extract_words(text_processed) for word_text in words_in_text: - lemma = get_lemma(word_text) + word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1) + lemma = get_lemma(word_normalized) + if lemma in conflict_lemma: return {"word": lemma, "type": "conflict_lemma"} - # === 4. SUBSTRING (подстроки) === + # === 4. SUBSTRING (подстроки) с нормализацией === substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING) for word in substring_words: - if word in text_processed: + # Нормализуем и банворд, и текст + 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" + ) return {"word": word, "type": "substring"} # === 5. PART (части слов без пробелов и спецсимволов) === part_words = self.manager.get_banwords_cached(BanWordType.PART) if part_words: # Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр - text_normalized = self._normalize_for_part_check(text) - - logger.debug( - f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'", - log_type="BANWORDS" - ) + text_part_normalized = self._normalize_for_part_check(text) + text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1) for part in part_words: - # Нормализуем само запрещенное слово тоже part_normalized = self._normalize_for_part_check(part) + part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1) - if part_normalized in text_normalized: + if part_normalized in text_part_normalized: logger.info( - f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') " - f"в тексте '{text_normalized[:100]}'", + f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') " + f"в '{text_part_normalized[:100]}'", log_type="BANWORDS" ) return {"word": part, "type": "part"} @@ -189,8 +234,15 @@ class BanWordsMiddleware(BaseMiddleware): if lemma_words: words_in_text = extract_words(text_processed) for word_text in words_in_text: - lemma = get_lemma(word_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"} # Банворды не найдены @@ -222,13 +274,13 @@ class BanWordsMiddleware(BaseMiddleware): f"Удалено сообщение от @{user.username or user.id} " f"(слово: '{matched_word}', тип: {match_type})", log_type="BANWORDS", - message=message + user=f"@{user.username}" if user.username else f"id{user.id}" ) except TelegramBadRequest as e: logger.error( f"Не удалось удалить сообщение: {e}", - log_type="ERROR", - message=message + log_type="BANWORDS", + user=f"@{user.username}" if user.username else f"id{user.id}" ) return @@ -311,7 +363,7 @@ class BanWordsMiddleware(BaseMiddleware): except Exception as e: logger.error( f"Ошибка отправки уведомления админам: {e}", - log_type="ERROR" + log_type="BANWORDS" ) @staticmethod diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py index bcd15b1..56dd14b 100644 --- a/bot/middlewares/spam_mdw.py +++ b/bot/middlewares/spam_mdw.py @@ -1,5 +1,6 @@ """ Умный middleware для защиты от спама с адаптивными лимитами +ВЕРСИЯ 2.0: мгновенная блокировка при явном флуде """ from time import time from typing import Callable, Awaitable, Any, Dict, Optional @@ -25,6 +26,7 @@ class MessageContext: is_command: bool = False media_type: Optional[str] = None callback_data: Optional[str] = None + timestamp: float = field(default_factory=time) @dataclass @@ -53,7 +55,7 @@ class UserSpamStats: # Разблокировка self.blocked_until = None - self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью + self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения return False def get_remaining_block_time(self, current_time: float) -> float: @@ -80,6 +82,7 @@ class UserSpamStats: def add_request(self, current_time: float, context: MessageContext) -> None: """Добавляет новый запрос с контекстом""" + context.timestamp = current_time self.request_times.append(current_time) self.message_contexts.append(context) self.total_requests += 1 @@ -103,9 +106,10 @@ class UserSpamStats: self.total_blocks += 1 self.reputation = max(0.5, self.reputation - 0.3) - def detect_spam_patterns(self) -> Dict[str, Any]: + def detect_spam_patterns(self, time_window: float = 10.0) -> Dict[str, Any]: """ Умная детекция спама на основе паттернов. + УЛУЧШЕНО: учитывает скорость отправки сообщений. Returns: Dict с результатами анализа @@ -113,46 +117,88 @@ class UserSpamStats: if len(self.message_contexts) < 3: return {'is_spam': False, 'reason': None, 'severity': 0.0} - recent_contexts = self.message_contexts[-10:] # Последние 10 сообщений + recent_contexts = self.message_contexts[-15:] # Последние 15 сообщений + current_time = time() - # 1. Проверка идентичных текстовых сообщений + # 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот) + # Если 5+ сообщений за 2 секунды => мгновенный мут + very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0] + if len(very_recent) >= 5: + return { + 'is_spam': True, + 'reason': 'extreme_flood', + 'severity': 1.0, + 'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды", + 'instant_block': True, + 'block_duration': 600.0 # 10 минут сразу + } + + # 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд + recent_5s = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 5.0] + if len(recent_5s) >= 8: + return { + 'is_spam': True, + 'reason': 'aggressive_flood', + 'severity': 0.95, + 'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд", + 'instant_block': True, + 'block_duration': 300.0 # 5 минут + } + + # 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 { + 'is_spam': True, + 'reason': 'media_flood_fast', + 'severity': 0.9, + 'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд", + 'instant_block': True, + 'block_duration': 240.0 # 4 минуты + } + + return { + 'is_spam': True, + 'reason': 'media_flood', + 'severity': 0.7, + 'details': f"📸 Медиа-флуд: {len(media_contexts)} файлов подряд" + } + + # 4. Проверка идентичных текстовых сообщений texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command] if texts: text_counts = Counter(texts) most_common_text, count = text_counts.most_common(1)[0] - if count >= 5: # 5 одинаковых сообщений подряд + if count >= 5: # 5 одинаковых сообщений return { 'is_spam': True, 'reason': 'identical_messages', - 'severity': 1.0, - 'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'" + 'severity': 0.85, + 'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)", + 'instant_block': True, + 'block_duration': 180.0 # 3 минуты } - # 2. Проверка спама callback кнопок + # 5. Проверка спама callback кнопок callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data] if callbacks: callback_counts = Counter(callbacks) most_common_callback, count = callback_counts.most_common(1)[0] - if count >= 8: # 8 нажатий одной кнопки + if count >= 10: # 10 нажатий одной кнопки return { 'is_spam': True, 'reason': 'callback_spam', 'severity': 0.8, - 'details': f"Спам кнопки: {most_common_callback}" + 'details': f"🔘 Спам кнопки: {count} нажатий", + 'instant_block': True, + 'block_duration': 120.0 # 2 минуты } - # 3. Проверка флуда медиа - media_types = [ctx.media_type for ctx in recent_contexts if ctx.media_type] - if len(media_types) >= 7: # 7+ медиафайлов подряд - return { - 'is_spam': True, - 'reason': 'media_flood', - 'severity': 0.6, - 'details': f"Флуд медиа: {len(media_types)} файлов" - } - return {'is_spam': False, 'reason': None, 'severity': 0.0} @@ -163,6 +209,7 @@ class SpamStatistics: self.users: Dict[int, UserSpamStats] = {} self.total_blocked_requests: int = 0 self.total_warnings_issued: int = 0 + self.instant_blocks: int = 0 def get_user(self, user_id: int) -> UserSpamStats: """Получает или создает статистику пользователя""" @@ -185,6 +232,7 @@ class SpamStatistics: 'total_users': len(self.users), 'total_blocked_requests': self.total_blocked_requests, 'total_warnings': self.total_warnings_issued, + 'instant_blocks': self.instant_blocks, 'active_blocks': sum( 1 for stats in self.users.values() if stats.blocked_until and stats.blocked_until > time() @@ -214,30 +262,29 @@ spam_stats = SpamStatistics() class AntiSpamMiddleware(BaseMiddleware): """ - Умный антиспам с адаптивными лимитами. + Умный антиспам с мгновенной блокировкой при флуде. - Особенности: - - Различает типы активности (текст, форварды, команды, callback) - - Адаптивные лимиты в зависимости от типа сообщения - - Система репутации пользователей - - Умная детекция спам-паттернов - - Мягкое отношение к пересылкам и ответам + Особенности v2: + - Мгновенная блокировка при экстремальном флуде (5+ сообщений за 2с) + - Детекция скорости отправки сообщений + - Адаптивная длительность блокировки + - Различает типы активности """ def __init__( self, - # Базовые лимиты - rate_limit_text: int = 8, # Текстовых сообщений за окно - rate_limit_forward: int = 20, # Пересылок за окно - rate_limit_callback: int = 10, # Нажатий кнопок за окно - rate_limit_media: int = 10, # Медиа за окно + # Базовые лимиты (мягкие, для накопления варнингов) + rate_limit_text: int = 8, + rate_limit_forward: int = 20, + rate_limit_callback: int = 12, + rate_limit_media: int = 10, - time_window: float = 10.0, # Временное окно (секунды) + time_window: float = 10.0, - # Предупреждения и блокировки + # Предупреждения (уже не так важны — флуд блокируется мгновенно) warning_limit: int = 3, - block_duration: float = 120.0, # 2 минуты базовая блокировка - max_block_duration: float = 3600.0, # 1 час максимум + base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги + max_block_duration: float = 3600.0, # Опции whitelist_admins: bool = True, @@ -253,7 +300,7 @@ class AntiSpamMiddleware(BaseMiddleware): self.rate_limit_media = rate_limit_media self.time_window = time_window self.warning_limit = warning_limit - self.block_duration = block_duration + self.base_block_duration = base_block_duration self.max_block_duration = max_block_duration self.whitelist_admins = whitelist_admins self.progressive_blocking = progressive_blocking @@ -292,7 +339,6 @@ class AntiSpamMiddleware(BaseMiddleware): def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int: """Вычисляет эффективный лимит с учётом типа и репутации""" - # Базовый лимит по типу if context.is_command: return 999 # Команды не ограничиваем elif context.callback_data: @@ -308,15 +354,15 @@ class AntiSpamMiddleware(BaseMiddleware): if self.enable_reputation: base_limit = int(base_limit * user_stats.reputation) - return max(3, base_limit) # Минимум 3 сообщения + return max(3, base_limit) def _calculate_block_duration(self, warnings: int) -> float: - """Вычисляет длительность блокировки""" + """Вычисляет длительность блокировки за накопленные варнинги""" if not self.progressive_blocking: - return self.block_duration + return self.base_block_duration multiplier = 2 ** (warnings // self.warning_limit) - duration = self.block_duration * multiplier + duration = self.base_block_duration * multiplier return min(duration, self.max_block_duration) @@ -357,7 +403,7 @@ class AntiSpamMiddleware(BaseMiddleware): current_time = time() user_stats = spam_stats.get_user(user_id) - # Проверка блокировки + # Проверка существующей блокировки if user_stats.is_blocked(current_time): remaining = user_stats.get_remaining_block_time(current_time) spam_stats.total_blocked_requests += 1 @@ -368,17 +414,10 @@ class AntiSpamMiddleware(BaseMiddleware): user=user_str ) - block_message = ( - f"🚫 Вы заблокированы за спам!\n\n" - f"⏳ Оставшееся время: {self._format_duration(remaining)}\n" - f"⚠️ Предупреждений: {user_stats.warnings}" - ) - - if isinstance(event, Message): - await event.answer(block_message, parse_mode="HTML") - elif isinstance(event, CallbackQuery): + # НЕ отправляем сообщение каждый раз — только callback answer + if isinstance(event, CallbackQuery): await event.answer( - f"🚫 Заблокирован на {self._format_duration(remaining)}", + f"🚫 Блокировка: {self._format_duration(remaining)}", show_alert=True ) @@ -387,51 +426,52 @@ 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) - # Умная детекция спам-паттернов + # ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ========== if self.enable_smart_detection: - spam_analysis = user_stats.detect_spam_patterns() + spam_analysis = user_stats.detect_spam_patterns(self.time_window) - if spam_analysis['is_spam']: - user_stats.add_warning() - spam_stats.total_warnings_issued += 1 + 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 # Максимум варнингов + spam_stats.instant_blocks += 1 - logger.warning( - f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}", + logger.error( + f"🚨 МГНОВЕННАЯ БЛОКИРОВКА! Причина: {spam_analysis['reason']}\n" + f" └─ {spam_analysis['details']}\n" + f" └─ Длительность: {self._format_duration(block_duration)}", log_type='ANTI_SPAM', user=user_str ) - # Немедленная блокировка при явном спаме - if spam_analysis['severity'] >= 0.9: - block_duration = self._calculate_block_duration(user_stats.warnings) - user_stats.block(current_time, block_duration) + block_message = ( + f"🚫 БЛОКИРОВКА ЗА ФЛУД!\n\n" + f"⚠️ {spam_analysis['details']}\n\n" + f"⏳ Длительность: {self._format_duration(block_duration)}\n" + f"💡 Не отправляйте сообщения слишком быстро!" + ) - logger.error( - f"Пользователь заблокирован за спам: {spam_analysis['reason']}", - log_type='ANTI_SPAM', - user=user_str - ) - - block_message = ( - f"🚫 Вы заблокированы за спам!\n\n" - f"⏳ Длительность: {self._format_duration(block_duration)}\n" - f"⚠️ Причина: {spam_analysis['details']}" - ) - - if isinstance(event, Message): + if isinstance(event, Message): + try: await event.answer(block_message, parse_mode="HTML") - elif isinstance(event, CallbackQuery): - await event.answer( - f"🚫 Блокировка: {spam_analysis['reason']}", - show_alert=True - ) + except: + pass + elif isinstance(event, CallbackQuery): + await event.answer( + f"🚫 Блокировка: {self._format_duration(block_duration)}", + show_alert=True + ) - return None + return None - # Получаем эффективный лимит + # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ========== effective_limit = self._get_effective_rate_limit(user_stats, context) # Подсчитываем релевантные запросы @@ -448,84 +488,71 @@ class AntiSpamMiddleware(BaseMiddleware): if self.log_all: logger.debug( - f"Rate limit: {relevant_requests}/{effective_limit} (тип: {context.media_type or 'text'}, репутация: {user_stats.reputation:.2f})", + f"Rate: {relevant_requests}/{effective_limit} | rep: {user_stats.reputation:.2f}", log_type='ANTI_SPAM', user=user_str ) - # Проверка лимита + # Мягкое превышение лимита if relevant_requests >= effective_limit: user_stats.add_warning() spam_stats.total_warnings_issued += 1 logger.warning( - f"Превышен rate limit ({relevant_requests}/{effective_limit}). " + f"Превышен лимит ({relevant_requests}/{effective_limit}). " f"Предупреждение {user_stats.warnings}/{self.warning_limit}", log_type='ANTI_SPAM', 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) logger.error( - f"Пользователь заблокирован на {self._format_duration(block_duration)}. " + f"Пользователь заблокирован на {self._format_duration(block_duration)} (варнинги). " f"Всего блокировок: {user_stats.total_blocks}", log_type='ANTI_SPAM', user=user_str ) block_message = ( - f"🚫 Вы заблокированы за спам!\n\n" + f"🚫 Вы заблокированы!\n\n" f"⏳ Длительность: {self._format_duration(block_duration)}\n" - f"⚠️ Причина: Превышение лимита запросов\n" - f"📊 Это блокировка #{user_stats.total_blocks}" + f"⚠️ Причина: Превышение лимита запросов" ) if isinstance(event, Message): - await event.answer(block_message, parse_mode="HTML") + try: + await event.answer(block_message, parse_mode="HTML") + except: + pass elif isinstance(event, CallbackQuery): await event.answer( - f"🚫 Блокировка на {self._format_duration(block_duration)}", + f"🚫 Блокировка: {self._format_duration(block_duration)}", show_alert=True ) return None - # Предупреждение - warning_message = ( - f"⚠️ Предупреждение #{user_stats.warnings}\n\n" - f"Вы отправляете запросы слишком часто!\n" - f"Лимит: {effective_limit} запросов за {self._format_duration(self.time_window)}\n\n" - f"При {self.warning_limit} предупреждениях последует блокировка." - ) - + # Предупреждение (только для сообщений, не для callback) if isinstance(event, Message): - await event.answer(warning_message, parse_mode="HTML") - elif isinstance(event, CallbackQuery): - await event.answer( - f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}", - show_alert=True + warning_message = ( + f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}\n\n" + f"Вы отправляете сообщения слишком часто!" ) + try: + await event.answer(warning_message, parse_mode="HTML") + except: + pass return None - # Добавляем текущий запрос - user_stats.add_request(current_time, context) - # Улучшаем репутацию за нормальное поведение if self.enable_reputation and user_stats.total_requests % 10 == 0: user_stats.improve_reputation() - if self.log_all: - logger.debug( - f"Запрос разрешен. Всего: {user_stats.total_requests}, репутация: {user_stats.reputation:.2f}", - log_type='ANTI_SPAM', - user=user_str - ) - return await handler(event, data) diff --git a/configs/cmd_alias_list.py b/configs/cmd_alias_list.py index eff3c27..b7ad3b9 100644 --- a/configs/cmd_alias_list.py +++ b/configs/cmd_alias_list.py @@ -360,4 +360,5 @@ COMMANDS: Final[dict[str, list[str]]] = { "дщпы", "kjub", # раскладка "log", "l", "лог", # сокращения ], +"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"], } diff --git a/configs/config.py b/configs/config.py index 9e8af4f..34384af 100644 --- a/configs/config.py +++ b/configs/config.py @@ -3,7 +3,7 @@ from urllib.parse import urlparse, ParseResult from typing import Optional, Any from secrets import token_urlsafe -from pydantic import field_validator, model_validator +from pydantic import field_validator, model_validator, Field from pydantic_settings import BaseSettings, SettingsConfigDict from aiogram.types import ChatAdministratorRights @@ -21,6 +21,7 @@ class _Settings(BaseSettings): # ============== ОСНОВНЫЕ ПАРАМЕТРЫ ============== # Токены бота BOT_TOKEN: Optional[str] = None + DATABASE_PATH: Optional[str] = "data/banwords.db" # Параметры сообщений PARSE_MODE: str = "HTML" @@ -61,6 +62,35 @@ class _Settings(BaseSettings): BOT_DESCRIPTION: Optional[str] = None BOT_SHORT_DESCRIPTION: Optional[str] = None + # ============ АВТОКОММЕНТАРИИ В КАНАЛЕ ============ + + AUTO_COMMENT_CHANNELS: str = Field( + default="", + description="ID каналов через запятую" + ) + + AUTO_COMMENT_TEXT: str = Field( + default="🔍 Нужна помощь?\n\nИспользуй наш сервис!", + description="Текст по умолчанию (HTML)" + ) + + AUTO_COMMENT_BUTTON_TEXT: str = Field( + default="🌐 Искать в Google", + description="Текст кнопки по умолчанию" + ) + + AUTO_COMMENT_BUTTON_URL: str = Field( + default="https://www.google.com", + description="URL кнопки по умолчанию" + ) + + AUTO_COMMENT_PHOTO_URL: str = Field( + default="https://via.placeholder.com/800x600.png", + description="URL фото по умолчанию" + ) + + + # Права администратора ANONYMOUS: bool = False MANAGE_CHAT: bool = True @@ -160,6 +190,25 @@ class _Settings(BaseSettings): return self # ================= СВОЙСТВА ================= + @property + def AUTO_COMMENT_CHANNELS_LIST(self) -> list[int]: + """Преобразует строку ID каналов в список""" + if not self.AUTO_COMMENT_CHANNELS: + return [] + + try: + return [ + int(channel_id.strip()) + for channel_id in self.AUTO_COMMENT_CHANNELS.split(",") + if channel_id.strip() + ] + except ValueError: + from middleware.loggers import logger # ✅ ДОБАВЬ ИМПОРТ + logger.error( + "Неверный формат AUTO_COMMENT_CHANNELS", + log_type="CONFIG" + ) + return [] @property def rights(self) -> ChatAdministratorRights: diff --git a/database/database.py b/database/database.py index 4198491..08685c3 100644 --- a/database/database.py +++ b/database/database.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import ( AsyncEngine ) +from configs import settings from middleware.loggers import logger from .models import Base @@ -26,7 +27,7 @@ class Database: session_factory: Фабрика сессий """ - def __init__(self, db_path: str = "banwords.db"): + def __init__(self, db_path: str = settings.DATABASE_PATH): """ Args: db_path: Путь к SQLite файлу @@ -99,7 +100,7 @@ class Database: _db_instance: Database | None = None -def get_db(db_path: str = "banwords.db") -> Database: +def get_db(db_path: str = settings.DATABASE_PATH) -> Database: """ Возвращает глобальный экземпляр Database (Singleton). diff --git a/database/manager.py b/database/manager.py index 486fb92..b2b019b 100644 --- a/database/manager.py +++ b/database/manager.py @@ -623,6 +623,98 @@ class BanWordsManager: await session.rollback() return False + # === AUTO COMMENTS === + + async def get_auto_comment_settings(self, channel_id: int) -> dict: + """ + Получает настройки автокомментариев для канала. + + Args: + channel_id: ID канала + + Returns: + dict: Настройки или значения по умолчанию + """ + 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 { + '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, # По умолчанию выключено + } + + async def save_auto_comment_settings( + self, + channel_id: int, + text: str, + button_text: str, + button_url: str, + photo_url: str, + updated_by: Optional[int] = None + ) -> bool: + """Сохраняет настройки автокомментариев""" + return await self.repo.set_auto_comment( + channel_id=channel_id, + text=text, + button_text=button_text, + button_url=button_url, + photo_url=photo_url, + updated_by=updated_by, + is_enabled=True + ) + + async def update_auto_comment_text( + self, + channel_id: int, + text: str, + updated_by: Optional[int] = None + ) -> bool: + """Обновляет текст автокомментария""" + return await self.repo.update_auto_comment_field( + channel_id, 'text', text, updated_by + ) + + async def update_auto_comment_button( + self, + channel_id: int, + button_text: str, + button_url: str, + updated_by: Optional[int] = None + ) -> bool: + """Обновляет кнопку автокомментария""" + success_text = await self.repo.update_auto_comment_field( + channel_id, 'button_text', button_text, updated_by + ) + success_url = await self.repo.update_auto_comment_field( + channel_id, 'button_url', button_url, updated_by + ) + return success_text and success_url + + async def update_auto_comment_photo( + self, + channel_id: int, + photo_url: str, + updated_by: Optional[int] = None + ) -> bool: + """Обновляет фото автокомментария""" + return await self.repo.update_auto_comment_field( + channel_id, 'photo_url', photo_url, updated_by + ) + # Глобальный экземпляр менеджера _manager_instance: Optional[BanWordsManager] = None diff --git a/database/models.py b/database/models.py index f0840e5..8bf4acc 100644 --- a/database/models.py +++ b/database/models.py @@ -19,6 +19,7 @@ __all__ = ( "Setting", "SpamStat", "SpamLog", +"AutoComment", ) @@ -252,3 +253,44 @@ class SpamLog(Base): DateTime, default=lambda: datetime.now(timezone.utc) ) + +class AutoComment(Base): + """ + Настройки автокомментариев для каналов. + + Attributes: + id: Уникальный ID + channel_id: ID канала (-100...) + text: Текст комментария (HTML) + button_text: Текст кнопки + button_url: URL кнопки + photo_url: URL фото для preview + is_enabled: Включены ли автокомментарии для этого канала + created_at: Дата создания + updated_at: Дата последнего обновления + updated_by: ID админа, который последним изменил + """ + __tablename__ = "auto_comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + text: Mapped[str] = mapped_column(Text, nullable=False) + button_text: Mapped[str] = mapped_column(String(100), nullable=False) + button_url: Mapped[str] = mapped_column(String(500), nullable=False) + photo_url: Mapped[str] = mapped_column(String(500), nullable=False) + is_enabled: Mapped[bool] = mapped_column(Integer, default=1, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False + ) + updated_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/database/repository.py b/database/repository.py index 70d084b..425711e 100644 --- a/database/repository.py +++ b/database/repository.py @@ -2,7 +2,7 @@ Repository для работы с банвордами через SQLAlchemy ORM. """ from typing import Set, List, Optional -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy import select, delete, func, and_ @@ -15,7 +15,8 @@ from .models import ( Admin, Setting, SpamStat, - BanWordType + BanWordType, + AutoComment ) __all__ = ("BanWordsRepository",) @@ -796,3 +797,252 @@ class BanWordsRepository: log_type="DATABASE" ) return {} + +# === AUTO COMMENTS === + + async def get_auto_comment(self, channel_id: int) -> Optional['AutoComment']: + """ + Получает настройки автокомментариев для канала. + + Args: + channel_id: ID канала + + Returns: + AutoComment или None + """ + try: + async with self.db.get_session() as session: + result = await session.execute( + select(AutoComment).where(AutoComment.channel_id == channel_id) + ) + return result.scalar_one_or_none() + except Exception as e: + logger.error( + f"Ошибка получения автокомментария: {e}", + log_type="DATABASE" + ) + return None + + async def set_auto_comment( + self, + channel_id: int, + text: str, + button_text: str, + button_url: str, + photo_url: str, + updated_by: Optional[int] = None, + is_enabled: bool = True + ) -> bool: + """ + Сохраняет или обновляет настройки автокомментариев. + + Args: + channel_id: ID канала + text: Текст комментария + button_text: Текст кнопки + button_url: URL кнопки + photo_url: URL фото + updated_by: ID админа + is_enabled: Включены ли комментарии + + Returns: + bool: True если успешно + """ + try: + async with self.db.get_session() as session: + # Проверяем существование + result = await session.execute( + select(AutoComment).where(AutoComment.channel_id == channel_id) + ) + auto_comment = result.scalar_one_or_none() + + if auto_comment: + # Обновляем существующую + auto_comment.text = text + auto_comment.button_text = button_text + auto_comment.button_url = button_url + auto_comment.photo_url = photo_url + auto_comment.is_enabled = is_enabled + auto_comment.updated_by = updated_by + auto_comment.updated_at = datetime.now(timezone.utc) + else: + # Создаём новую + auto_comment = AutoComment( + channel_id=channel_id, + text=text, + button_text=button_text, + button_url=button_url, + photo_url=photo_url, + is_enabled=is_enabled, + updated_by=updated_by + ) + session.add(auto_comment) + + await session.commit() + + logger.info( + f"Автокомментарий для канала {channel_id} обновлён", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка сохранения автокомментария: {e}", + log_type="DATABASE" + ) + return False + + async def update_auto_comment_field( + self, + channel_id: int, + field: str, + value: str, + updated_by: Optional[int] = None + ) -> bool: + """ + Обновляет одно поле автокомментария. + + Args: + channel_id: ID канала + field: Имя поля (text, button_text, button_url, photo_url) + value: Новое значение + updated_by: ID админа + + Returns: + bool: True если успешно + """ + try: + + async with self.db.get_session() as session: + result = await session.execute( + select(AutoComment).where(AutoComment.channel_id == channel_id) + ) + auto_comment = result.scalar_one_or_none() + + if not auto_comment: + return False + + # Обновляем поле + if hasattr(auto_comment, field): + setattr(auto_comment, field, value) + auto_comment.updated_by = updated_by + auto_comment.updated_at = datetime.now(timezone.utc) + await session.commit() + + logger.info( + f"Поле '{field}' автокомментария для канала {channel_id} обновлено", + log_type="DATABASE" + ) + return True + else: + logger.error( + f"Поле '{field}' не существует в AutoComment", + log_type="DATABASE" + ) + return False + + except Exception as e: + logger.error( + f"Ошибка обновления поля автокомментария: {e}", + log_type="DATABASE" + ) + return False + + async def toggle_auto_comment( + self, + channel_id: int, + is_enabled: bool, + updated_by: Optional[int] = None + ) -> bool: + """ + Включает/выключает автокомментарии для канала. + + Args: + channel_id: ID канала + is_enabled: True - включить, False - выключить + updated_by: ID админа + + Returns: + bool: True если успешно + """ + try: + + async with self.db.get_session() as session: + result = await session.execute( + select(AutoComment).where(AutoComment.channel_id == channel_id) + ) + auto_comment = result.scalar_one_or_none() + + if not auto_comment: + return False + + auto_comment.is_enabled = is_enabled + auto_comment.updated_by = updated_by + auto_comment.updated_at = datetime.now(timezone.utc) + await session.commit() + + logger.info( + f"Автокомментарии для канала {channel_id} {'включены' if is_enabled else 'выключены'}", + log_type="DATABASE" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка переключения автокомментария: {e}", + log_type="DATABASE" + ) + return False + + async def get_all_auto_comments(self) -> list['AutoComment']: + """ + Получает все настройки автокомментариев. + + Returns: + List[AutoComment]: Список всех автокомментариев + """ + try: + + async with self.db.get_session() as session: + result = await session.execute(select(AutoComment)) + return list(result.scalars().all()) + except Exception as e: + logger.error( + f"Ошибка получения всех автокомментариев: {e}", + log_type="DATABASE" + ) + return [] + + async def delete_auto_comment(self, channel_id: int) -> bool: + """ + Удаляет настройки автокомментариев для канала. + + Args: + channel_id: ID канала + + Returns: + bool: True если удалено + """ + try: + + async with self.db.get_session() as session: + result = await session.execute( + delete(AutoComment).where(AutoComment.channel_id == channel_id) + ) + await session.commit() + deleted = result.rowcount > 0 + + if deleted: + logger.info( + f"Автокомментарий для канала {channel_id} удалён", + log_type="DATABASE" + ) + return deleted + + except Exception as e: + logger.error( + f"Ошибка удаления автокомментария: {e}", + log_type="DATABASE" + ) + return False