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