Астат ты не вознесешься

This commit is contained in:
2026-02-18 01:43:22 +07:00
parent 59a3a7b46a
commit 5d350d0885
15 changed files with 1489 additions and 183 deletions

View File

@@ -257,3 +257,13 @@ KEEP_BACKUPS=7
# Возраст старой статистики для удаления (дни)
STATS_MAX_AGE_DAYS=30
# ============ АВТОКОММЕНТАРИИ ============
# ID каналов через запятую (узнать через @userinfobot)
AUTO_COMMENT_CHANNELS=-1001234567890
# Настройки по умолчанию (будут использоваться для новых каналов)
AUTO_COMMENT_TEXT=🔍 <b>Нужна помощь?</b>\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

View File

@@ -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"]

View File

@@ -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(

View File

@@ -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,
)

770
bot/handlers/chl_comment.py Normal file
View File

@@ -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 = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
# 1) ENV settings
channels = settings.AUTO_COMMENT_CHANNELS_LIST
diagnostic_text += "1⃣ <b>Настройки:</b>\n"
diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: <code>{channels}</code>\n"
diagnostic_text += f" └─ Канал в списке: {'' if channel_id in channels else ''}\n\n"
# 2) DB config
diagnostic_text += "2⃣ <b>База данных:</b>\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⃣ <b>Бот в канале:</b>\n"
try:
member = await bot.get_chat_member(channel_id, bot.id)
diagnostic_text += f" ├─ Статус: <code>{member.status}</code>\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⃣ <b>Привязанная группа обсуждений:</b>\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: <code>{linked_chat_id}</code>\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⃣ <b>Бот в группе обсуждений:</b>\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" ├─ Статус: <code>{gmember.status}</code>\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 += "💡 <b>Что должно быть для работы:</b>\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(
"❌ <b>Каналы не настроены</b>\n\n"
"Добавьте ID каналов в .env файл:\n"
"<code>AUTO_COMMENT_CHANNELS=-1003876862007</code>\n\n"
"💡 Узнать ID канала: перешлите пост из канала боту @userinfobot",
parse_mode="HTML"
)
return
if len(channels) == 1:
await show_channel_menu(message, channels[0])
else:
await message.answer(
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\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=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
"Отправьте новый текст комментария.\n\n"
"💡 <b>Поддерживается HTML</b>\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(
"❌ <b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment",
parse_mode="HTML"
)
return
await message.answer(f"✅ <b>Текст обновлён!</b>", 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=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
"<b>Шаг 1 из 2:</b> Отправьте текст кнопки\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"✅ Текст кнопки: <b>{message.text}</b>\n\n"
f"<b>Шаг 2 из 2:</b> Отправьте 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(
"❌ <b>Неверный формат URL</b>\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("✅ <b>Кнопка обновлена!</b>", 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=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\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(
"❌ <b>Неверный формат URL</b>\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) + "✅ <b>Фото обновлено!</b>", 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"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\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=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
f"Автокомментарии для канала <code>{channel_id}</code> удалены.\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("✅ Действие отменено")

View File

@@ -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"❌ Использование: <code>/{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}</code>"
usage = f"/{command} &lt;слово&gt;" if min_args == 1 else f"/{command} &lt;слово&gt; &lt;минуты&gt;"
return False, f"❌ Использование: <code>{usage}</code>"
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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка добавления</b>\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("❌ <b>Ошибка удаления</b>\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("❌ <b>Ошибка удаления</b>\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("❌ <b>Ошибка удаления</b>\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("❌ <b>Ошибка удаления</b>\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("❌ <b>Ошибка удаления</b>\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("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")

View File

@@ -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

View File

@@ -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)
# === 1. WHITELIST (исключения) ===
if self.manager.is_whitelisted(text_processed):
# Дополнительно нормализуем повторяющиеся буквы для всех проверок
text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1)
logger.debug(
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', "
f"нормализованный='{text_normalized[:50]}'",
log_type="BANWORDS"
)
# === 1. WHITELIST (исключения) ===
# Проверяем оба варианта: с повторами и без
if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized):
logger.debug(
f"Сообщение содержит whitelist слово",
log_type="BANWORDS"
)
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

View File

@@ -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,44 +117,86 @@ 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}"
}
# 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)} файлов"
'details': f"🔘 Спам кнопки: {count} нажатий",
'instant_block': True,
'block_duration': 120.0 # 2 минуты
}
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"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"⏳ Оставшееся время: <b>{self._format_duration(remaining)}</b>\n"
f"⚠️ Предупреждений: <b>{user_stats.warnings}</b>"
)
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
logger.warning(
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
log_type='ANTI_SPAM',
user=user_str
)
# Немедленная блокировка при явном спаме
if spam_analysis['severity'] >= 0.9:
block_duration = self._calculate_block_duration(user_stats.warnings)
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.error(
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
f"🚨 МГНОВЕННАЯ БЛОКИРОВКА! Причина: {spam_analysis['reason']}\n"
f" └─ {spam_analysis['details']}\n"
f" └─ Длительность: {self._format_duration(block_duration)}",
log_type='ANTI_SPAM',
user=user_str
)
block_message = (
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"🚫 <b>БЛОКИРОВКА ЗА ФЛУД!</b>\n\n"
f"⚠️ {spam_analysis['details']}\n\n"
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
f"⚠️ Причина: {spam_analysis['details']}"
f"💡 Не отправляйте сообщения слишком быстро!"
)
if isinstance(event, Message):
try:
await event.answer(block_message, parse_mode="HTML")
except:
pass
elif isinstance(event, CallbackQuery):
await event.answer(
f"🚫 Блокировка: {spam_analysis['reason']}",
f"🚫 Блокировка: {self._format_duration(block_duration)}",
show_alert=True
)
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"🚫 <b>Вы заблокированы за спам!</b>\n\n"
f"🚫 <b>Вы заблокированы!</b>\n\n"
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
f"⚠️ Причина: Превышение лимита запросов\n"
f"📊 Это блокировка #{user_stats.total_blocks}"
f"⚠️ Причина: Превышение лимита запросов"
)
if isinstance(event, Message):
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"⚠️ <b>Предупреждение #{user_stats.warnings}</b>\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"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\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)

View File

@@ -360,4 +360,5 @@ COMMANDS: Final[dict[str, list[str]]] = {
"дщпы", "kjub", # раскладка
"log", "l", "лог", # сокращения
],
"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
}

View File

@@ -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="🔍 <b>Нужна помощь?</b>\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:

View File

@@ -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).

View File

@@ -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

View File

@@ -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"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>"

View File

@@ -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