Астат ты не вознесешься
This commit is contained in:
10
.env_example
10
.env_example
@@ -257,3 +257,13 @@ KEEP_BACKUPS=7
|
|||||||
# Возраст старой статистики для удаления (дни)
|
# Возраст старой статистики для удаления (дни)
|
||||||
STATS_MAX_AGE_DAYS=30
|
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
|
||||||
@@ -12,6 +12,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
# Копируем все файлы проекта
|
# Копируем все файлы проекта
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN mkdir -p /app/data && chmod -R 777 /app/data
|
||||||
|
|
||||||
# Запускаем бота
|
# Запускаем бота
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|||||||
@@ -123,8 +123,18 @@ class BotInfo:
|
|||||||
# Устанавливаем webhook
|
# Устанавливаем webhook
|
||||||
await bots.set_webhook(
|
await bots.set_webhook(
|
||||||
url=settings.WEBHOOK_URL,
|
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,
|
secret_token=settings.SECRET_TOKEN,
|
||||||
drop_pending_updates=True
|
drop_pending_updates=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from aiogram import Router
|
|||||||
|
|
||||||
from .commands import router as cmd_routers
|
from .commands import router as cmd_routers
|
||||||
from .messages import router as messages_routers
|
from .messages import router as messages_routers
|
||||||
|
from .chl_comment import router as channels_routers
|
||||||
|
|
||||||
# Настройка экспорта и роутера
|
# Настройка экспорта и роутера
|
||||||
__all__ = ("router",)
|
__all__ = ("router",)
|
||||||
@@ -9,6 +10,7 @@ router: Router = Router(name=__name__)
|
|||||||
|
|
||||||
# Подключение роутеров
|
# Подключение роутеров
|
||||||
router.include_routers(
|
router.include_routers(
|
||||||
|
channels_routers,
|
||||||
cmd_routers,
|
cmd_routers,
|
||||||
messages_routers,
|
messages_routers,
|
||||||
)
|
)
|
||||||
|
|||||||
770
bot/handlers/chl_comment.py
Normal file
770
bot/handlers/chl_comment.py
Normal 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("✅ Действие отменено")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Обработчики команд добавления и удаления банвордов
|
Обработчики команд добавления и удаления банвордов
|
||||||
"""
|
"""
|
||||||
from aiogram import Router, F
|
from aiogram import Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message
|
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)
|
parts = text.split(maxsplit=max_args)
|
||||||
|
|
||||||
if len(parts) < min_args + 1:
|
if len(parts) < min_args + 1:
|
||||||
return False, f"❌ Использование: <code>/{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}</code>"
|
usage = f"/{command} <слово>" if min_args == 1 else f"/{command} <слово> <минуты>"
|
||||||
|
return False, f"❌ Использование: <code>{usage}</code>"
|
||||||
|
|
||||||
args = parts[1:]
|
args = parts[1:]
|
||||||
|
|
||||||
@@ -71,6 +72,20 @@ def format_success_message(action: str, word: str, word_type: str, extra: str =
|
|||||||
return message
|
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())
|
@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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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 added:
|
||||||
# Форматируем время
|
time_str = format_time(minutes)
|
||||||
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}д"
|
|
||||||
|
|
||||||
text = format_success_message(
|
text = format_success_message(
|
||||||
"добавлена",
|
"добавлена",
|
||||||
word,
|
word,
|
||||||
@@ -261,7 +265,7 @@ async def add_temp_word_cmd(message: Message) -> None:
|
|||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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 added:
|
||||||
if minutes < 60:
|
time_str = format_time(minutes)
|
||||||
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}д"
|
|
||||||
|
|
||||||
text = format_success_message(
|
text = format_success_message(
|
||||||
"добавлена",
|
"добавлена",
|
||||||
word,
|
word,
|
||||||
@@ -325,7 +319,7 @@ async def add_temp_lemma_cmd(message: Message) -> None:
|
|||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
except Exception as e:
|
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")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ def setup_middlewares(
|
|||||||
if enable_subscription_check:
|
if enable_subscription_check:
|
||||||
enabled_features.append("Subscription")
|
enabled_features.append("Subscription")
|
||||||
|
|
||||||
|
dp.channel_post.middleware(LoggingMiddleware())
|
||||||
|
dp.edited_channel_post.middleware(LoggingMiddleware())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
text=(
|
text=(
|
||||||
f"Middleware зарегистрированы: "
|
f"Middleware зарегистрированы: "
|
||||||
@@ -135,3 +138,5 @@ def setup_middlewares(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return instances
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Pipeline проверки:
|
|||||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
5. Проверяем постоянные банворды (substring, lemma, part)
|
||||||
6. Проверяем временные банворды
|
6. Проверяем временные банворды
|
||||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
7. Если найдено - удаляем, логируем, уведомляем админов
|
||||||
|
|
||||||
|
НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
|
||||||
"""
|
"""
|
||||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||||
import re
|
import re
|
||||||
@@ -103,9 +105,36 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
"hello@world.com" -> "helloworldcom"
|
"hello@world.com" -> "helloworldcom"
|
||||||
"test_123-456" -> "test123456"
|
"test_123-456" -> "test123456"
|
||||||
"""
|
"""
|
||||||
# Оставляем только буквы и цифры
|
|
||||||
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
|
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]]:
|
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Проверяет сообщение на наличие банвордов.
|
Проверяет сообщение на наличие банвордов.
|
||||||
@@ -120,10 +149,20 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
text_lower = text.lower()
|
text_lower = text.lower()
|
||||||
text_processed = process_text(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 (исключения) ===
|
# === 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(
|
logger.debug(
|
||||||
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
|
f"Сообщение содержит whitelist слово",
|
||||||
log_type="BANWORDS"
|
log_type="BANWORDS"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -137,12 +176,13 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
# === 3. CONFLICT MODE (конфликтные слова) ===
|
||||||
if await self.manager.is_conflict_active():
|
if await self.manager.is_conflict_active():
|
||||||
# Проверяем конфликтные подстроки
|
# Проверяем конфликтные подстроки (с нормализацией)
|
||||||
conflict_substring = self.manager.get_banwords_cached(
|
conflict_substring = self.manager.get_banwords_cached(
|
||||||
BanWordType.CONFLICT_SUBSTRING
|
BanWordType.CONFLICT_SUBSTRING
|
||||||
)
|
)
|
||||||
for word in 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"}
|
return {"word": word, "type": "conflict_substring"}
|
||||||
|
|
||||||
# Проверяем конфликтные леммы
|
# Проверяем конфликтные леммы
|
||||||
@@ -151,35 +191,40 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
)
|
)
|
||||||
words_in_text = extract_words(text_processed)
|
words_in_text = extract_words(text_processed)
|
||||||
for word_text in words_in_text:
|
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:
|
if lemma in conflict_lemma:
|
||||||
return {"word": lemma, "type": "conflict_lemma"}
|
return {"word": lemma, "type": "conflict_lemma"}
|
||||||
|
|
||||||
# === 4. SUBSTRING (подстроки) ===
|
# === 4. SUBSTRING (подстроки) с нормализацией ===
|
||||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||||
for word in substring_words:
|
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"}
|
return {"word": word, "type": "substring"}
|
||||||
|
|
||||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
||||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||||
if part_words:
|
if part_words:
|
||||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
||||||
text_normalized = self._normalize_for_part_check(text)
|
text_part_normalized = self._normalize_for_part_check(text)
|
||||||
|
text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1)
|
||||||
logger.debug(
|
|
||||||
f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
|
|
||||||
for part in part_words:
|
for part in part_words:
|
||||||
# Нормализуем само запрещенное слово тоже
|
|
||||||
part_normalized = self._normalize_for_part_check(part)
|
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(
|
logger.info(
|
||||||
f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') "
|
f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') "
|
||||||
f"в тексте '{text_normalized[:100]}'",
|
f"в '{text_part_normalized[:100]}'",
|
||||||
log_type="BANWORDS"
|
log_type="BANWORDS"
|
||||||
)
|
)
|
||||||
return {"word": part, "type": "part"}
|
return {"word": part, "type": "part"}
|
||||||
@@ -189,8 +234,15 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
if lemma_words:
|
if lemma_words:
|
||||||
words_in_text = extract_words(text_processed)
|
words_in_text = extract_words(text_processed)
|
||||||
for word_text in words_in_text:
|
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:
|
if lemma in lemma_words:
|
||||||
|
logger.info(
|
||||||
|
f"Найдена лемма: '{lemma}' из слова '{word_text}' (норм: '{word_normalized}')",
|
||||||
|
log_type="BANWORDS"
|
||||||
|
)
|
||||||
return {"word": lemma, "type": "lemma"}
|
return {"word": lemma, "type": "lemma"}
|
||||||
|
|
||||||
# Банворды не найдены
|
# Банворды не найдены
|
||||||
@@ -222,13 +274,13 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
f"Удалено сообщение от @{user.username or user.id} "
|
f"Удалено сообщение от @{user.username or user.id} "
|
||||||
f"(слово: '{matched_word}', тип: {match_type})",
|
f"(слово: '{matched_word}', тип: {match_type})",
|
||||||
log_type="BANWORDS",
|
log_type="BANWORDS",
|
||||||
message=message
|
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||||
)
|
)
|
||||||
except TelegramBadRequest as e:
|
except TelegramBadRequest as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Не удалось удалить сообщение: {e}",
|
f"Не удалось удалить сообщение: {e}",
|
||||||
log_type="ERROR",
|
log_type="BANWORDS",
|
||||||
message=message
|
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -311,7 +363,7 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Ошибка отправки уведомления админам: {e}",
|
f"Ошибка отправки уведомления админам: {e}",
|
||||||
log_type="ERROR"
|
log_type="BANWORDS"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Умный middleware для защиты от спама с адаптивными лимитами
|
Умный middleware для защиты от спама с адаптивными лимитами
|
||||||
|
ВЕРСИЯ 2.0: мгновенная блокировка при явном флуде
|
||||||
"""
|
"""
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||||
@@ -25,6 +26,7 @@ class MessageContext:
|
|||||||
is_command: bool = False
|
is_command: bool = False
|
||||||
media_type: Optional[str] = None
|
media_type: Optional[str] = None
|
||||||
callback_data: Optional[str] = None
|
callback_data: Optional[str] = None
|
||||||
|
timestamp: float = field(default_factory=time)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -53,7 +55,7 @@ class UserSpamStats:
|
|||||||
|
|
||||||
# Разблокировка
|
# Разблокировка
|
||||||
self.blocked_until = None
|
self.blocked_until = None
|
||||||
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью
|
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_remaining_block_time(self, current_time: float) -> float:
|
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:
|
def add_request(self, current_time: float, context: MessageContext) -> None:
|
||||||
"""Добавляет новый запрос с контекстом"""
|
"""Добавляет новый запрос с контекстом"""
|
||||||
|
context.timestamp = current_time
|
||||||
self.request_times.append(current_time)
|
self.request_times.append(current_time)
|
||||||
self.message_contexts.append(context)
|
self.message_contexts.append(context)
|
||||||
self.total_requests += 1
|
self.total_requests += 1
|
||||||
@@ -103,9 +106,10 @@ class UserSpamStats:
|
|||||||
self.total_blocks += 1
|
self.total_blocks += 1
|
||||||
self.reputation = max(0.5, self.reputation - 0.3)
|
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:
|
Returns:
|
||||||
Dict с результатами анализа
|
Dict с результатами анализа
|
||||||
@@ -113,46 +117,88 @@ class UserSpamStats:
|
|||||||
if len(self.message_contexts) < 3:
|
if len(self.message_contexts) < 3:
|
||||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
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]
|
texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command]
|
||||||
if texts:
|
if texts:
|
||||||
text_counts = Counter(texts)
|
text_counts = Counter(texts)
|
||||||
most_common_text, count = text_counts.most_common(1)[0]
|
most_common_text, count = text_counts.most_common(1)[0]
|
||||||
|
|
||||||
if count >= 5: # 5 одинаковых сообщений подряд
|
if count >= 5: # 5 одинаковых сообщений
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
'reason': 'identical_messages',
|
'reason': 'identical_messages',
|
||||||
'severity': 1.0,
|
'severity': 0.85,
|
||||||
'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'"
|
'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]
|
callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data]
|
||||||
if callbacks:
|
if callbacks:
|
||||||
callback_counts = Counter(callbacks)
|
callback_counts = Counter(callbacks)
|
||||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||||
|
|
||||||
if count >= 8: # 8 нажатий одной кнопки
|
if count >= 10: # 10 нажатий одной кнопки
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
'reason': 'callback_spam',
|
'reason': 'callback_spam',
|
||||||
'severity': 0.8,
|
'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}
|
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +209,7 @@ class SpamStatistics:
|
|||||||
self.users: Dict[int, UserSpamStats] = {}
|
self.users: Dict[int, UserSpamStats] = {}
|
||||||
self.total_blocked_requests: int = 0
|
self.total_blocked_requests: int = 0
|
||||||
self.total_warnings_issued: int = 0
|
self.total_warnings_issued: int = 0
|
||||||
|
self.instant_blocks: int = 0
|
||||||
|
|
||||||
def get_user(self, user_id: int) -> UserSpamStats:
|
def get_user(self, user_id: int) -> UserSpamStats:
|
||||||
"""Получает или создает статистику пользователя"""
|
"""Получает или создает статистику пользователя"""
|
||||||
@@ -185,6 +232,7 @@ class SpamStatistics:
|
|||||||
'total_users': len(self.users),
|
'total_users': len(self.users),
|
||||||
'total_blocked_requests': self.total_blocked_requests,
|
'total_blocked_requests': self.total_blocked_requests,
|
||||||
'total_warnings': self.total_warnings_issued,
|
'total_warnings': self.total_warnings_issued,
|
||||||
|
'instant_blocks': self.instant_blocks,
|
||||||
'active_blocks': sum(
|
'active_blocks': sum(
|
||||||
1 for stats in self.users.values()
|
1 for stats in self.users.values()
|
||||||
if stats.blocked_until and stats.blocked_until > time()
|
if stats.blocked_until and stats.blocked_until > time()
|
||||||
@@ -214,30 +262,29 @@ spam_stats = SpamStatistics()
|
|||||||
|
|
||||||
class AntiSpamMiddleware(BaseMiddleware):
|
class AntiSpamMiddleware(BaseMiddleware):
|
||||||
"""
|
"""
|
||||||
Умный антиспам с адаптивными лимитами.
|
Умный антиспам с мгновенной блокировкой при флуде.
|
||||||
|
|
||||||
Особенности:
|
Особенности v2:
|
||||||
- Различает типы активности (текст, форварды, команды, callback)
|
- Мгновенная блокировка при экстремальном флуде (5+ сообщений за 2с)
|
||||||
- Адаптивные лимиты в зависимости от типа сообщения
|
- Детекция скорости отправки сообщений
|
||||||
- Система репутации пользователей
|
- Адаптивная длительность блокировки
|
||||||
- Умная детекция спам-паттернов
|
- Различает типы активности
|
||||||
- Мягкое отношение к пересылкам и ответам
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
# Базовые лимиты
|
# Базовые лимиты (мягкие, для накопления варнингов)
|
||||||
rate_limit_text: int = 8, # Текстовых сообщений за окно
|
rate_limit_text: int = 8,
|
||||||
rate_limit_forward: int = 20, # Пересылок за окно
|
rate_limit_forward: int = 20,
|
||||||
rate_limit_callback: int = 10, # Нажатий кнопок за окно
|
rate_limit_callback: int = 12,
|
||||||
rate_limit_media: int = 10, # Медиа за окно
|
rate_limit_media: int = 10,
|
||||||
|
|
||||||
time_window: float = 10.0, # Временное окно (секунды)
|
time_window: float = 10.0,
|
||||||
|
|
||||||
# Предупреждения и блокировки
|
# Предупреждения (уже не так важны — флуд блокируется мгновенно)
|
||||||
warning_limit: int = 3,
|
warning_limit: int = 3,
|
||||||
block_duration: float = 120.0, # 2 минуты базовая блокировка
|
base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги
|
||||||
max_block_duration: float = 3600.0, # 1 час максимум
|
max_block_duration: float = 3600.0,
|
||||||
|
|
||||||
# Опции
|
# Опции
|
||||||
whitelist_admins: bool = True,
|
whitelist_admins: bool = True,
|
||||||
@@ -253,7 +300,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
self.rate_limit_media = rate_limit_media
|
self.rate_limit_media = rate_limit_media
|
||||||
self.time_window = time_window
|
self.time_window = time_window
|
||||||
self.warning_limit = warning_limit
|
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.max_block_duration = max_block_duration
|
||||||
self.whitelist_admins = whitelist_admins
|
self.whitelist_admins = whitelist_admins
|
||||||
self.progressive_blocking = progressive_blocking
|
self.progressive_blocking = progressive_blocking
|
||||||
@@ -292,7 +339,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
|
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
|
||||||
"""Вычисляет эффективный лимит с учётом типа и репутации"""
|
"""Вычисляет эффективный лимит с учётом типа и репутации"""
|
||||||
# Базовый лимит по типу
|
|
||||||
if context.is_command:
|
if context.is_command:
|
||||||
return 999 # Команды не ограничиваем
|
return 999 # Команды не ограничиваем
|
||||||
elif context.callback_data:
|
elif context.callback_data:
|
||||||
@@ -308,15 +354,15 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
if self.enable_reputation:
|
if self.enable_reputation:
|
||||||
base_limit = int(base_limit * user_stats.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:
|
def _calculate_block_duration(self, warnings: int) -> float:
|
||||||
"""Вычисляет длительность блокировки"""
|
"""Вычисляет длительность блокировки за накопленные варнинги"""
|
||||||
if not self.progressive_blocking:
|
if not self.progressive_blocking:
|
||||||
return self.block_duration
|
return self.base_block_duration
|
||||||
|
|
||||||
multiplier = 2 ** (warnings // self.warning_limit)
|
multiplier = 2 ** (warnings // self.warning_limit)
|
||||||
duration = self.block_duration * multiplier
|
duration = self.base_block_duration * multiplier
|
||||||
|
|
||||||
return min(duration, self.max_block_duration)
|
return min(duration, self.max_block_duration)
|
||||||
|
|
||||||
@@ -357,7 +403,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
current_time = time()
|
current_time = time()
|
||||||
user_stats = spam_stats.get_user(user_id)
|
user_stats = spam_stats.get_user(user_id)
|
||||||
|
|
||||||
# Проверка блокировки
|
# Проверка существующей блокировки
|
||||||
if user_stats.is_blocked(current_time):
|
if user_stats.is_blocked(current_time):
|
||||||
remaining = user_stats.get_remaining_block_time(current_time)
|
remaining = user_stats.get_remaining_block_time(current_time)
|
||||||
spam_stats.total_blocked_requests += 1
|
spam_stats.total_blocked_requests += 1
|
||||||
@@ -368,17 +414,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
block_message = (
|
# НЕ отправляем сообщение каждый раз — только callback answer
|
||||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
if isinstance(event, CallbackQuery):
|
||||||
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):
|
|
||||||
await event.answer(
|
await event.answer(
|
||||||
f"🚫 Заблокирован на {self._format_duration(remaining)}",
|
f"🚫 Блокировка: {self._format_duration(remaining)}",
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -387,51 +426,52 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
# Извлекаем контекст сообщения
|
# Извлекаем контекст сообщения
|
||||||
context = self._extract_context(event)
|
context = self._extract_context(event)
|
||||||
|
|
||||||
|
# Добавляем запрос СНАЧАЛА (важно для детекции скорости)
|
||||||
|
user_stats.add_request(current_time, context)
|
||||||
|
|
||||||
# Очищаем старые запросы
|
# Очищаем старые запросы
|
||||||
user_stats.clean_old_requests(current_time, self.time_window)
|
user_stats.clean_old_requests(current_time, self.time_window)
|
||||||
|
|
||||||
# Умная детекция спам-паттернов
|
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
|
||||||
if self.enable_smart_detection:
|
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']:
|
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
|
||||||
user_stats.add_warning()
|
# МГНОВЕННАЯ БЛОКИРОВКА
|
||||||
spam_stats.total_warnings_issued += 1
|
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(
|
logger.error(
|
||||||
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
|
f"🚨 МГНОВЕННАЯ БЛОКИРОВКА! Причина: {spam_analysis['reason']}\n"
|
||||||
|
f" └─ {spam_analysis['details']}\n"
|
||||||
|
f" └─ Длительность: {self._format_duration(block_duration)}",
|
||||||
log_type='ANTI_SPAM',
|
log_type='ANTI_SPAM',
|
||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# Немедленная блокировка при явном спаме
|
block_message = (
|
||||||
if spam_analysis['severity'] >= 0.9:
|
f"🚫 <b>БЛОКИРОВКА ЗА ФЛУД!</b>\n\n"
|
||||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
f"⚠️ {spam_analysis['details']}\n\n"
|
||||||
user_stats.block(current_time, block_duration)
|
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||||
|
f"💡 Не отправляйте сообщения слишком быстро!"
|
||||||
|
)
|
||||||
|
|
||||||
logger.error(
|
if isinstance(event, Message):
|
||||||
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
|
try:
|
||||||
log_type='ANTI_SPAM',
|
|
||||||
user=user_str
|
|
||||||
)
|
|
||||||
|
|
||||||
block_message = (
|
|
||||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
|
||||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
|
||||||
f"⚠️ Причина: {spam_analysis['details']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(event, Message):
|
|
||||||
await event.answer(block_message, parse_mode="HTML")
|
await event.answer(block_message, parse_mode="HTML")
|
||||||
elif isinstance(event, CallbackQuery):
|
except:
|
||||||
await event.answer(
|
pass
|
||||||
f"🚫 Блокировка: {spam_analysis['reason']}",
|
elif isinstance(event, CallbackQuery):
|
||||||
show_alert=True
|
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)
|
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
||||||
|
|
||||||
# Подсчитываем релевантные запросы
|
# Подсчитываем релевантные запросы
|
||||||
@@ -448,84 +488,71 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
if self.log_all:
|
if self.log_all:
|
||||||
logger.debug(
|
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',
|
log_type='ANTI_SPAM',
|
||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверка лимита
|
# Мягкое превышение лимита
|
||||||
if relevant_requests >= effective_limit:
|
if relevant_requests >= effective_limit:
|
||||||
user_stats.add_warning()
|
user_stats.add_warning()
|
||||||
spam_stats.total_warnings_issued += 1
|
spam_stats.total_warnings_issued += 1
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Превышен rate limit ({relevant_requests}/{effective_limit}). "
|
f"Превышен лимит ({relevant_requests}/{effective_limit}). "
|
||||||
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||||
log_type='ANTI_SPAM',
|
log_type='ANTI_SPAM',
|
||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# Блокировка при достижении лимита предупреждений
|
# Блокировка при достижении лимита варнингов
|
||||||
if user_stats.warnings >= self.warning_limit:
|
if user_stats.warnings >= self.warning_limit:
|
||||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||||
user_stats.block(current_time, block_duration)
|
user_stats.block(current_time, block_duration)
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Пользователь заблокирован на {self._format_duration(block_duration)}. "
|
f"Пользователь заблокирован на {self._format_duration(block_duration)} (варнинги). "
|
||||||
f"Всего блокировок: {user_stats.total_blocks}",
|
f"Всего блокировок: {user_stats.total_blocks}",
|
||||||
log_type='ANTI_SPAM',
|
log_type='ANTI_SPAM',
|
||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
block_message = (
|
block_message = (
|
||||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
f"🚫 <b>Вы заблокированы!</b>\n\n"
|
||||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||||
f"⚠️ Причина: Превышение лимита запросов\n"
|
f"⚠️ Причина: Превышение лимита запросов"
|
||||||
f"📊 Это блокировка #{user_stats.total_blocks}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(event, Message):
|
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):
|
elif isinstance(event, CallbackQuery):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
f"🚫 Блокировка на {self._format_duration(block_duration)}",
|
f"🚫 Блокировка: {self._format_duration(block_duration)}",
|
||||||
show_alert=True
|
show_alert=True
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Предупреждение
|
# Предупреждение (только для сообщений, не для callback)
|
||||||
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} предупреждениях последует блокировка."
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
await event.answer(warning_message, parse_mode="HTML")
|
warning_message = (
|
||||||
elif isinstance(event, CallbackQuery):
|
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
|
||||||
await event.answer(
|
f"Вы отправляете сообщения слишком часто!"
|
||||||
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
|
||||||
show_alert=True
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await event.answer(warning_message, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Добавляем текущий запрос
|
|
||||||
user_stats.add_request(current_time, context)
|
|
||||||
|
|
||||||
# Улучшаем репутацию за нормальное поведение
|
# Улучшаем репутацию за нормальное поведение
|
||||||
if self.enable_reputation and user_stats.total_requests % 10 == 0:
|
if self.enable_reputation and user_stats.total_requests % 10 == 0:
|
||||||
user_stats.improve_reputation()
|
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)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -360,4 +360,5 @@ COMMANDS: Final[dict[str, list[str]]] = {
|
|||||||
"дщпы", "kjub", # раскладка
|
"дщпы", "kjub", # раскладка
|
||||||
"log", "l", "лог", # сокращения
|
"log", "l", "лог", # сокращения
|
||||||
],
|
],
|
||||||
|
"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from urllib.parse import urlparse, ParseResult
|
|||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from secrets import token_urlsafe
|
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 pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from aiogram.types import ChatAdministratorRights
|
from aiogram.types import ChatAdministratorRights
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ class _Settings(BaseSettings):
|
|||||||
# ============== ОСНОВНЫЕ ПАРАМЕТРЫ ==============
|
# ============== ОСНОВНЫЕ ПАРАМЕТРЫ ==============
|
||||||
# Токены бота
|
# Токены бота
|
||||||
BOT_TOKEN: Optional[str] = None
|
BOT_TOKEN: Optional[str] = None
|
||||||
|
DATABASE_PATH: Optional[str] = "data/banwords.db"
|
||||||
|
|
||||||
# Параметры сообщений
|
# Параметры сообщений
|
||||||
PARSE_MODE: str = "HTML"
|
PARSE_MODE: str = "HTML"
|
||||||
@@ -61,6 +62,35 @@ class _Settings(BaseSettings):
|
|||||||
BOT_DESCRIPTION: Optional[str] = None
|
BOT_DESCRIPTION: Optional[str] = None
|
||||||
BOT_SHORT_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
|
ANONYMOUS: bool = False
|
||||||
MANAGE_CHAT: bool = True
|
MANAGE_CHAT: bool = True
|
||||||
@@ -160,6 +190,25 @@ class _Settings(BaseSettings):
|
|||||||
return self
|
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
|
@property
|
||||||
def rights(self) -> ChatAdministratorRights:
|
def rights(self) -> ChatAdministratorRights:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
AsyncEngine
|
AsyncEngine
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from configs import settings
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
from .models import Base
|
from .models import Base
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ class Database:
|
|||||||
session_factory: Фабрика сессий
|
session_factory: Фабрика сессий
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db_path: str = "banwords.db"):
|
def __init__(self, db_path: str = settings.DATABASE_PATH):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
db_path: Путь к SQLite файлу
|
db_path: Путь к SQLite файлу
|
||||||
@@ -99,7 +100,7 @@ class Database:
|
|||||||
_db_instance: Database | None = None
|
_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).
|
Возвращает глобальный экземпляр Database (Singleton).
|
||||||
|
|
||||||
|
|||||||
@@ -623,6 +623,98 @@ class BanWordsManager:
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
return False
|
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
|
_manager_instance: Optional[BanWordsManager] = None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ __all__ = (
|
|||||||
"Setting",
|
"Setting",
|
||||||
"SpamStat",
|
"SpamStat",
|
||||||
"SpamLog",
|
"SpamLog",
|
||||||
|
"AutoComment",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,3 +253,44 @@ class SpamLog(Base):
|
|||||||
DateTime,
|
DateTime,
|
||||||
default=lambda: datetime.now(timezone.utc)
|
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})>"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Repository для работы с банвордами через SQLAlchemy ORM.
|
Repository для работы с банвордами через SQLAlchemy ORM.
|
||||||
"""
|
"""
|
||||||
from typing import Set, List, Optional
|
from typing import Set, List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select, delete, func, and_
|
from sqlalchemy import select, delete, func, and_
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ from .models import (
|
|||||||
Admin,
|
Admin,
|
||||||
Setting,
|
Setting,
|
||||||
SpamStat,
|
SpamStat,
|
||||||
BanWordType
|
BanWordType,
|
||||||
|
AutoComment
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ("BanWordsRepository",)
|
__all__ = ("BanWordsRepository",)
|
||||||
@@ -796,3 +797,252 @@ class BanWordsRepository:
|
|||||||
log_type="DATABASE"
|
log_type="DATABASE"
|
||||||
)
|
)
|
||||||
return {}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user