Астат ты не вознесешься
This commit is contained in:
10
.env_example
10
.env_example
@@ -257,3 +257,13 @@ KEEP_BACKUPS=7
|
||||
# Возраст старой статистики для удаления (дни)
|
||||
STATS_MAX_AGE_DAYS=30
|
||||
|
||||
|
||||
# ============ АВТОКОММЕНТАРИИ ============
|
||||
# ID каналов через запятую (узнать через @userinfobot)
|
||||
AUTO_COMMENT_CHANNELS=-1001234567890
|
||||
|
||||
# Настройки по умолчанию (будут использоваться для новых каналов)
|
||||
AUTO_COMMENT_TEXT=🔍 <b>Нужна помощь?</b>\n\nИспользуй наш сервис!
|
||||
AUTO_COMMENT_BUTTON_TEXT=🌐 Искать в Google
|
||||
AUTO_COMMENT_BUTTON_URL=https://www.google.com
|
||||
AUTO_COMMENT_PHOTO_URL=https://via.placeholder.com/800x600.png
|
||||
@@ -12,6 +12,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копируем все файлы проекта
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data && chmod -R 777 /app/data
|
||||
|
||||
# Запускаем бота
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -123,8 +123,18 @@ class BotInfo:
|
||||
# Устанавливаем webhook
|
||||
await bots.set_webhook(
|
||||
url=settings.WEBHOOK_URL,
|
||||
allowed_updates=[
|
||||
"message",
|
||||
"edited_message",
|
||||
"channel_post", # ← ВОТ ЭТО ДОБАВЬ!
|
||||
"edited_channel_post", # ← И ЭТО
|
||||
"callback_query",
|
||||
"inline_query",
|
||||
"my_chat_member",
|
||||
"chat_member"
|
||||
],
|
||||
secret_token=settings.SECRET_TOKEN,
|
||||
drop_pending_updates=True
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
|
||||
logger.success(
|
||||
|
||||
@@ -2,6 +2,7 @@ from aiogram import Router
|
||||
|
||||
from .commands import router as cmd_routers
|
||||
from .messages import router as messages_routers
|
||||
from .chl_comment import router as channels_routers
|
||||
|
||||
# Настройка экспорта и роутера
|
||||
__all__ = ("router",)
|
||||
@@ -9,6 +10,7 @@ router: Router = Router(name=__name__)
|
||||
|
||||
# Подключение роутеров
|
||||
router.include_routers(
|
||||
channels_routers,
|
||||
cmd_routers,
|
||||
messages_routers,
|
||||
)
|
||||
|
||||
770
bot/handlers/chl_comment.py
Normal file
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.types import Message
|
||||
|
||||
@@ -36,7 +36,8 @@ def parse_args(text: str, command: str, min_args: int = 1, max_args: int = 2) ->
|
||||
parts = text.split(maxsplit=max_args)
|
||||
|
||||
if len(parts) < min_args + 1:
|
||||
return False, f"❌ Использование: <code>/{command} {'<слово>' if min_args == 1 else '<слово> <минуты>'}</code>"
|
||||
usage = f"/{command} <слово>" if min_args == 1 else f"/{command} <слово> <минуты>"
|
||||
return False, f"❌ Использование: <code>{usage}</code>"
|
||||
|
||||
args = parts[1:]
|
||||
|
||||
@@ -71,6 +72,20 @@ def format_success_message(action: str, word: str, word_type: str, extra: str =
|
||||
return message
|
||||
|
||||
|
||||
def format_time(minutes: int) -> str:
|
||||
"""Форматирует время в читаемый вид"""
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
return f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
return f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
|
||||
# ================= КОМАНДЫ ДОБАВЛЕНИЯ =================
|
||||
|
||||
@router.message(Command(*COMMANDS.get("addword", ["addword"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||
@@ -111,7 +126,7 @@ async def add_word_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -153,7 +168,7 @@ async def add_lemma_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -195,7 +210,7 @@ async def add_part_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления части: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления части: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -237,18 +252,7 @@ async def add_temp_word_cmd(message: Message) -> None:
|
||||
)
|
||||
|
||||
if added:
|
||||
# Форматируем время
|
||||
if minutes < 60:
|
||||
time_str = f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
time_str = format_time(minutes)
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
@@ -261,7 +265,7 @@ async def add_temp_word_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -302,17 +306,7 @@ async def add_temp_lemma_cmd(message: Message) -> None:
|
||||
)
|
||||
|
||||
if added:
|
||||
if minutes < 60:
|
||||
time_str = f"{minutes} мин"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}ч {mins}м" if mins else f"{hours}ч"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
hours = (minutes % 1440) // 60
|
||||
time_str = f"{days}д {hours}ч" if hours else f"{days}д"
|
||||
|
||||
time_str = format_time(minutes)
|
||||
text = format_success_message(
|
||||
"добавлена",
|
||||
word,
|
||||
@@ -325,7 +319,7 @@ async def add_temp_lemma_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -366,7 +360,7 @@ async def add_exception_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -400,7 +394,7 @@ async def remove_word_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -428,7 +422,7 @@ async def remove_lemma_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -456,7 +450,7 @@ async def remove_part_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления части: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления части: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -485,7 +479,7 @@ async def remove_temp_word_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -514,7 +508,7 @@ async def remove_temp_lemma_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
|
||||
@@ -542,5 +536,5 @@ async def remove_exception_cmd(message: Message) -> None:
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD")
|
||||
logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD", exc_info=True)
|
||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||
|
||||
@@ -124,6 +124,9 @@ def setup_middlewares(
|
||||
if enable_subscription_check:
|
||||
enabled_features.append("Subscription")
|
||||
|
||||
dp.channel_post.middleware(LoggingMiddleware())
|
||||
dp.edited_channel_post.middleware(LoggingMiddleware())
|
||||
|
||||
logger.info(
|
||||
text=(
|
||||
f"Middleware зарегистрированы: "
|
||||
@@ -135,3 +138,5 @@ def setup_middlewares(
|
||||
)
|
||||
|
||||
return instances
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ Pipeline проверки:
|
||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
||||
6. Проверяем временные банворды
|
||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
||||
|
||||
НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
|
||||
"""
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
import re
|
||||
@@ -103,9 +105,36 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
"hello@world.com" -> "helloworldcom"
|
||||
"test_123-456" -> "test123456"
|
||||
"""
|
||||
# Оставляем только буквы и цифры
|
||||
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str:
|
||||
"""
|
||||
Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог").
|
||||
|
||||
Args:
|
||||
text: Исходное слово
|
||||
max_repeats: Максимальное количество повторов одной буквы (1 = убрать все повторы)
|
||||
|
||||
Returns:
|
||||
str: Нормализованное слово
|
||||
|
||||
Examples:
|
||||
("лееейн", 1) -> "лейн"
|
||||
("телееелооог", 1) -> "телелог"
|
||||
("хеееелооооу", 1) -> "хелоу"
|
||||
("аааааа", 1) -> "а"
|
||||
("привеееет", 2) -> "приввеет" (если max_repeats=2)
|
||||
"""
|
||||
if max_repeats == 1:
|
||||
# Заменяем 2+ одинаковых букв подряд на 1 такую же букву
|
||||
return re.sub(r'([а-яёa-z])\1+', r'\1', text, flags=re.IGNORECASE)
|
||||
else:
|
||||
# Заменяем (max_repeats+1)+ одинаковых букв на max_repeats таких букв
|
||||
pattern = f'([а-яёa-z])\\1{{{max_repeats},}}'
|
||||
replacement = '\\1' * max_repeats
|
||||
return re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
||||
|
||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Проверяет сообщение на наличие банвордов.
|
||||
@@ -120,10 +149,20 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
text_lower = text.lower()
|
||||
text_processed = process_text(text_lower)
|
||||
|
||||
# Дополнительно нормализуем повторяющиеся буквы для всех проверок
|
||||
text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', "
|
||||
f"нормализованный='{text_normalized[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
|
||||
# === 1. WHITELIST (исключения) ===
|
||||
if self.manager.is_whitelisted(text_processed):
|
||||
# Проверяем оба варианта: с повторами и без
|
||||
if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized):
|
||||
logger.debug(
|
||||
f"Сообщение содержит whitelist слово: '{text_processed[:50]}'",
|
||||
f"Сообщение содержит whitelist слово",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return None
|
||||
@@ -137,12 +176,13 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
|
||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
||||
if await self.manager.is_conflict_active():
|
||||
# Проверяем конфликтные подстроки
|
||||
# Проверяем конфликтные подстроки (с нормализацией)
|
||||
conflict_substring = self.manager.get_banwords_cached(
|
||||
BanWordType.CONFLICT_SUBSTRING
|
||||
)
|
||||
for word in conflict_substring:
|
||||
if word in text_processed:
|
||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
||||
if word_normalized in text_normalized:
|
||||
return {"word": word, "type": "conflict_substring"}
|
||||
|
||||
# Проверяем конфликтные леммы
|
||||
@@ -151,35 +191,40 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
)
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
lemma = get_lemma(word_text)
|
||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
||||
lemma = get_lemma(word_normalized)
|
||||
|
||||
if lemma in conflict_lemma:
|
||||
return {"word": lemma, "type": "conflict_lemma"}
|
||||
|
||||
# === 4. SUBSTRING (подстроки) ===
|
||||
# === 4. SUBSTRING (подстроки) с нормализацией ===
|
||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||
for word in substring_words:
|
||||
if word in text_processed:
|
||||
# Нормализуем и банворд, и текст
|
||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
||||
|
||||
if word_normalized in text_normalized:
|
||||
logger.info(
|
||||
f"Найдена подстрока: '{word}' (норм: '{word_normalized}') в '{text_normalized[:100]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": word, "type": "substring"}
|
||||
|
||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||
if part_words:
|
||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
||||
text_normalized = self._normalize_for_part_check(text)
|
||||
|
||||
logger.debug(
|
||||
f"Проверка PART: исходный='{text[:50]}', нормализованный='{text_normalized[:50]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
text_part_normalized = self._normalize_for_part_check(text)
|
||||
text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1)
|
||||
|
||||
for part in part_words:
|
||||
# Нормализуем само запрещенное слово тоже
|
||||
part_normalized = self._normalize_for_part_check(part)
|
||||
part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1)
|
||||
|
||||
if part_normalized in text_normalized:
|
||||
if part_normalized in text_part_normalized:
|
||||
logger.info(
|
||||
f"Найдена запрещенная часть: '{part}' (нормализовано: '{part_normalized}') "
|
||||
f"в тексте '{text_normalized[:100]}'",
|
||||
f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') "
|
||||
f"в '{text_part_normalized[:100]}'",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": part, "type": "part"}
|
||||
@@ -189,8 +234,15 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
if lemma_words:
|
||||
words_in_text = extract_words(text_processed)
|
||||
for word_text in words_in_text:
|
||||
lemma = get_lemma(word_text)
|
||||
# Убираем повторяющиеся буквы ПЕРЕД лемматизацией
|
||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
||||
lemma = get_lemma(word_normalized)
|
||||
|
||||
if lemma in lemma_words:
|
||||
logger.info(
|
||||
f"Найдена лемма: '{lemma}' из слова '{word_text}' (норм: '{word_normalized}')",
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
return {"word": lemma, "type": "lemma"}
|
||||
|
||||
# Банворды не найдены
|
||||
@@ -222,13 +274,13 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
f"Удалено сообщение от @{user.username or user.id} "
|
||||
f"(слово: '{matched_word}', тип: {match_type})",
|
||||
log_type="BANWORDS",
|
||||
message=message
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить сообщение: {e}",
|
||||
log_type="ERROR",
|
||||
message=message
|
||||
log_type="BANWORDS",
|
||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -311,7 +363,7 @@ class BanWordsMiddleware(BaseMiddleware):
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления админам: {e}",
|
||||
log_type="ERROR"
|
||||
log_type="BANWORDS"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Умный middleware для защиты от спама с адаптивными лимитами
|
||||
ВЕРСИЯ 2.0: мгновенная блокировка при явном флуде
|
||||
"""
|
||||
from time import time
|
||||
from typing import Callable, Awaitable, Any, Dict, Optional
|
||||
@@ -25,6 +26,7 @@ class MessageContext:
|
||||
is_command: bool = False
|
||||
media_type: Optional[str] = None
|
||||
callback_data: Optional[str] = None
|
||||
timestamp: float = field(default_factory=time)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -53,7 +55,7 @@ class UserSpamStats:
|
||||
|
||||
# Разблокировка
|
||||
self.blocked_until = None
|
||||
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения, но не сбрасываем полностью
|
||||
self.warnings = max(0, self.warnings - 1) # Снижаем предупреждения
|
||||
return False
|
||||
|
||||
def get_remaining_block_time(self, current_time: float) -> float:
|
||||
@@ -80,6 +82,7 @@ class UserSpamStats:
|
||||
|
||||
def add_request(self, current_time: float, context: MessageContext) -> None:
|
||||
"""Добавляет новый запрос с контекстом"""
|
||||
context.timestamp = current_time
|
||||
self.request_times.append(current_time)
|
||||
self.message_contexts.append(context)
|
||||
self.total_requests += 1
|
||||
@@ -103,9 +106,10 @@ class UserSpamStats:
|
||||
self.total_blocks += 1
|
||||
self.reputation = max(0.5, self.reputation - 0.3)
|
||||
|
||||
def detect_spam_patterns(self) -> Dict[str, Any]:
|
||||
def detect_spam_patterns(self, time_window: float = 10.0) -> Dict[str, Any]:
|
||||
"""
|
||||
Умная детекция спама на основе паттернов.
|
||||
УЛУЧШЕНО: учитывает скорость отправки сообщений.
|
||||
|
||||
Returns:
|
||||
Dict с результатами анализа
|
||||
@@ -113,46 +117,88 @@ class UserSpamStats:
|
||||
if len(self.message_contexts) < 3:
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
recent_contexts = self.message_contexts[-10:] # Последние 10 сообщений
|
||||
recent_contexts = self.message_contexts[-15:] # Последние 15 сообщений
|
||||
current_time = time()
|
||||
|
||||
# 1. Проверка идентичных текстовых сообщений
|
||||
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
||||
# Если 5+ сообщений за 2 секунды => мгновенный мут
|
||||
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
|
||||
if len(very_recent) >= 5:
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'extreme_flood',
|
||||
'severity': 1.0,
|
||||
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
|
||||
'instant_block': True,
|
||||
'block_duration': 600.0 # 10 минут сразу
|
||||
}
|
||||
|
||||
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
||||
recent_5s = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 5.0]
|
||||
if len(recent_5s) >= 8:
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'aggressive_flood',
|
||||
'severity': 0.95,
|
||||
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
|
||||
'instant_block': True,
|
||||
'block_duration': 300.0 # 5 минут
|
||||
}
|
||||
|
||||
# 3. Медиа-флуд (стикеры/фото/видео)
|
||||
media_contexts = [ctx for ctx in recent_contexts if ctx.media_type]
|
||||
if len(media_contexts) >= 7:
|
||||
# Проверяем скорость отправки медиа
|
||||
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
|
||||
if len(media_recent) >= 6:
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'media_flood_fast',
|
||||
'severity': 0.9,
|
||||
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
|
||||
'instant_block': True,
|
||||
'block_duration': 240.0 # 4 минуты
|
||||
}
|
||||
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'media_flood',
|
||||
'severity': 0.7,
|
||||
'details': f"📸 Медиа-флуд: {len(media_contexts)} файлов подряд"
|
||||
}
|
||||
|
||||
# 4. Проверка идентичных текстовых сообщений
|
||||
texts = [ctx.text for ctx in recent_contexts if ctx.text and not ctx.is_command]
|
||||
if texts:
|
||||
text_counts = Counter(texts)
|
||||
most_common_text, count = text_counts.most_common(1)[0]
|
||||
|
||||
if count >= 5: # 5 одинаковых сообщений подряд
|
||||
if count >= 5: # 5 одинаковых сообщений
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'identical_messages',
|
||||
'severity': 1.0,
|
||||
'details': f"Повторяющееся сообщение: '{most_common_text[:50]}...'"
|
||||
'severity': 0.85,
|
||||
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
|
||||
'instant_block': True,
|
||||
'block_duration': 180.0 # 3 минуты
|
||||
}
|
||||
|
||||
# 2. Проверка спама callback кнопок
|
||||
# 5. Проверка спама callback кнопок
|
||||
callbacks = [ctx.callback_data for ctx in recent_contexts if ctx.callback_data]
|
||||
if callbacks:
|
||||
callback_counts = Counter(callbacks)
|
||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||
|
||||
if count >= 8: # 8 нажатий одной кнопки
|
||||
if count >= 10: # 10 нажатий одной кнопки
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'callback_spam',
|
||||
'severity': 0.8,
|
||||
'details': f"Спам кнопки: {most_common_callback}"
|
||||
'details': f"🔘 Спам кнопки: {count} нажатий",
|
||||
'instant_block': True,
|
||||
'block_duration': 120.0 # 2 минуты
|
||||
}
|
||||
|
||||
# 3. Проверка флуда медиа
|
||||
media_types = [ctx.media_type for ctx in recent_contexts if ctx.media_type]
|
||||
if len(media_types) >= 7: # 7+ медиафайлов подряд
|
||||
return {
|
||||
'is_spam': True,
|
||||
'reason': 'media_flood',
|
||||
'severity': 0.6,
|
||||
'details': f"Флуд медиа: {len(media_types)} файлов"
|
||||
}
|
||||
|
||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||
|
||||
|
||||
@@ -163,6 +209,7 @@ class SpamStatistics:
|
||||
self.users: Dict[int, UserSpamStats] = {}
|
||||
self.total_blocked_requests: int = 0
|
||||
self.total_warnings_issued: int = 0
|
||||
self.instant_blocks: int = 0
|
||||
|
||||
def get_user(self, user_id: int) -> UserSpamStats:
|
||||
"""Получает или создает статистику пользователя"""
|
||||
@@ -185,6 +232,7 @@ class SpamStatistics:
|
||||
'total_users': len(self.users),
|
||||
'total_blocked_requests': self.total_blocked_requests,
|
||||
'total_warnings': self.total_warnings_issued,
|
||||
'instant_blocks': self.instant_blocks,
|
||||
'active_blocks': sum(
|
||||
1 for stats in self.users.values()
|
||||
if stats.blocked_until and stats.blocked_until > time()
|
||||
@@ -214,30 +262,29 @@ spam_stats = SpamStatistics()
|
||||
|
||||
class AntiSpamMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Умный антиспам с адаптивными лимитами.
|
||||
Умный антиспам с мгновенной блокировкой при флуде.
|
||||
|
||||
Особенности:
|
||||
- Различает типы активности (текст, форварды, команды, callback)
|
||||
- Адаптивные лимиты в зависимости от типа сообщения
|
||||
- Система репутации пользователей
|
||||
- Умная детекция спам-паттернов
|
||||
- Мягкое отношение к пересылкам и ответам
|
||||
Особенности v2:
|
||||
- Мгновенная блокировка при экстремальном флуде (5+ сообщений за 2с)
|
||||
- Детекция скорости отправки сообщений
|
||||
- Адаптивная длительность блокировки
|
||||
- Различает типы активности
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Базовые лимиты
|
||||
rate_limit_text: int = 8, # Текстовых сообщений за окно
|
||||
rate_limit_forward: int = 20, # Пересылок за окно
|
||||
rate_limit_callback: int = 10, # Нажатий кнопок за окно
|
||||
rate_limit_media: int = 10, # Медиа за окно
|
||||
# Базовые лимиты (мягкие, для накопления варнингов)
|
||||
rate_limit_text: int = 8,
|
||||
rate_limit_forward: int = 20,
|
||||
rate_limit_callback: int = 12,
|
||||
rate_limit_media: int = 10,
|
||||
|
||||
time_window: float = 10.0, # Временное окно (секунды)
|
||||
time_window: float = 10.0,
|
||||
|
||||
# Предупреждения и блокировки
|
||||
# Предупреждения (уже не так важны — флуд блокируется мгновенно)
|
||||
warning_limit: int = 3,
|
||||
block_duration: float = 120.0, # 2 минуты базовая блокировка
|
||||
max_block_duration: float = 3600.0, # 1 час максимум
|
||||
base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги
|
||||
max_block_duration: float = 3600.0,
|
||||
|
||||
# Опции
|
||||
whitelist_admins: bool = True,
|
||||
@@ -253,7 +300,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
self.rate_limit_media = rate_limit_media
|
||||
self.time_window = time_window
|
||||
self.warning_limit = warning_limit
|
||||
self.block_duration = block_duration
|
||||
self.base_block_duration = base_block_duration
|
||||
self.max_block_duration = max_block_duration
|
||||
self.whitelist_admins = whitelist_admins
|
||||
self.progressive_blocking = progressive_blocking
|
||||
@@ -292,7 +339,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
|
||||
def _get_effective_rate_limit(self, user_stats: UserSpamStats, context: MessageContext) -> int:
|
||||
"""Вычисляет эффективный лимит с учётом типа и репутации"""
|
||||
# Базовый лимит по типу
|
||||
if context.is_command:
|
||||
return 999 # Команды не ограничиваем
|
||||
elif context.callback_data:
|
||||
@@ -308,15 +354,15 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
if self.enable_reputation:
|
||||
base_limit = int(base_limit * user_stats.reputation)
|
||||
|
||||
return max(3, base_limit) # Минимум 3 сообщения
|
||||
return max(3, base_limit)
|
||||
|
||||
def _calculate_block_duration(self, warnings: int) -> float:
|
||||
"""Вычисляет длительность блокировки"""
|
||||
"""Вычисляет длительность блокировки за накопленные варнинги"""
|
||||
if not self.progressive_blocking:
|
||||
return self.block_duration
|
||||
return self.base_block_duration
|
||||
|
||||
multiplier = 2 ** (warnings // self.warning_limit)
|
||||
duration = self.block_duration * multiplier
|
||||
duration = self.base_block_duration * multiplier
|
||||
|
||||
return min(duration, self.max_block_duration)
|
||||
|
||||
@@ -357,7 +403,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
current_time = time()
|
||||
user_stats = spam_stats.get_user(user_id)
|
||||
|
||||
# Проверка блокировки
|
||||
# Проверка существующей блокировки
|
||||
if user_stats.is_blocked(current_time):
|
||||
remaining = user_stats.get_remaining_block_time(current_time)
|
||||
spam_stats.total_blocked_requests += 1
|
||||
@@ -368,17 +414,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"⏳ Оставшееся время: <b>{self._format_duration(remaining)}</b>\n"
|
||||
f"⚠️ Предупреждений: <b>{user_stats.warnings}</b>"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
# НЕ отправляем сообщение каждый раз — только callback answer
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Заблокирован на {self._format_duration(remaining)}",
|
||||
f"🚫 Блокировка: {self._format_duration(remaining)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
@@ -387,51 +426,52 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
# Извлекаем контекст сообщения
|
||||
context = self._extract_context(event)
|
||||
|
||||
# Добавляем запрос СНАЧАЛА (важно для детекции скорости)
|
||||
user_stats.add_request(current_time, context)
|
||||
|
||||
# Очищаем старые запросы
|
||||
user_stats.clean_old_requests(current_time, self.time_window)
|
||||
|
||||
# Умная детекция спам-паттернов
|
||||
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
|
||||
if self.enable_smart_detection:
|
||||
spam_analysis = user_stats.detect_spam_patterns()
|
||||
spam_analysis = user_stats.detect_spam_patterns(self.time_window)
|
||||
|
||||
if spam_analysis['is_spam']:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
|
||||
# МГНОВЕННАЯ БЛОКИРОВКА
|
||||
block_duration = spam_analysis.get('block_duration', 300.0)
|
||||
user_stats.block(current_time, block_duration)
|
||||
user_stats.warnings = self.warning_limit # Максимум варнингов
|
||||
spam_stats.instant_blocks += 1
|
||||
|
||||
logger.warning(
|
||||
f"Обнаружен спам-паттерн: {spam_analysis['reason']} - {spam_analysis['details']}",
|
||||
logger.error(
|
||||
f"🚨 МГНОВЕННАЯ БЛОКИРОВКА! Причина: {spam_analysis['reason']}\n"
|
||||
f" └─ {spam_analysis['details']}\n"
|
||||
f" └─ Длительность: {self._format_duration(block_duration)}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Немедленная блокировка при явном спаме
|
||||
if spam_analysis['severity'] >= 0.9:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
block_message = (
|
||||
f"🚫 <b>БЛОКИРОВКА ЗА ФЛУД!</b>\n\n"
|
||||
f"⚠️ {spam_analysis['details']}\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"💡 Не отправляйте сообщения слишком быстро!"
|
||||
)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован за спам: {spam_analysis['reason']}",
|
||||
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):
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка: {spam_analysis['reason']}",
|
||||
show_alert=True
|
||||
)
|
||||
except:
|
||||
pass
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка: {self._format_duration(block_duration)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
# Получаем эффективный лимит
|
||||
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ==========
|
||||
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
||||
|
||||
# Подсчитываем релевантные запросы
|
||||
@@ -448,84 +488,71 @@ class AntiSpamMiddleware(BaseMiddleware):
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Rate limit: {relevant_requests}/{effective_limit} (тип: {context.media_type or 'text'}, репутация: {user_stats.reputation:.2f})",
|
||||
f"Rate: {relevant_requests}/{effective_limit} | rep: {user_stats.reputation:.2f}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Проверка лимита
|
||||
# Мягкое превышение лимита
|
||||
if relevant_requests >= effective_limit:
|
||||
user_stats.add_warning()
|
||||
spam_stats.total_warnings_issued += 1
|
||||
|
||||
logger.warning(
|
||||
f"Превышен rate limit ({relevant_requests}/{effective_limit}). "
|
||||
f"Превышен лимит ({relevant_requests}/{effective_limit}). "
|
||||
f"Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
# Блокировка при достижении лимита предупреждений
|
||||
# Блокировка при достижении лимита варнингов
|
||||
if user_stats.warnings >= self.warning_limit:
|
||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||
user_stats.block(current_time, block_duration)
|
||||
|
||||
logger.error(
|
||||
f"Пользователь заблокирован на {self._format_duration(block_duration)}. "
|
||||
f"Пользователь заблокирован на {self._format_duration(block_duration)} (варнинги). "
|
||||
f"Всего блокировок: {user_stats.total_blocks}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
block_message = (
|
||||
f"🚫 <b>Вы заблокированы за спам!</b>\n\n"
|
||||
f"🚫 <b>Вы заблокированы!</b>\n\n"
|
||||
f"⏳ Длительность: <b>{self._format_duration(block_duration)}</b>\n"
|
||||
f"⚠️ Причина: Превышение лимита запросов\n"
|
||||
f"📊 Это блокировка #{user_stats.total_blocks}"
|
||||
f"⚠️ Причина: Превышение лимита запросов"
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
try:
|
||||
await event.answer(block_message, parse_mode="HTML")
|
||||
except:
|
||||
pass
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"🚫 Блокировка на {self._format_duration(block_duration)}",
|
||||
f"🚫 Блокировка: {self._format_duration(block_duration)}",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# Предупреждение
|
||||
warning_message = (
|
||||
f"⚠️ <b>Предупреждение #{user_stats.warnings}</b>\n\n"
|
||||
f"Вы отправляете запросы слишком часто!\n"
|
||||
f"Лимит: {effective_limit} запросов за {self._format_duration(self.time_window)}\n\n"
|
||||
f"При {self.warning_limit} предупреждениях последует блокировка."
|
||||
)
|
||||
|
||||
# Предупреждение (только для сообщений, не для callback)
|
||||
if isinstance(event, Message):
|
||||
await event.answer(warning_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}",
|
||||
show_alert=True
|
||||
warning_message = (
|
||||
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
|
||||
f"Вы отправляете сообщения слишком часто!"
|
||||
)
|
||||
try:
|
||||
await event.answer(warning_message, parse_mode="HTML")
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# Добавляем текущий запрос
|
||||
user_stats.add_request(current_time, context)
|
||||
|
||||
# Улучшаем репутацию за нормальное поведение
|
||||
if self.enable_reputation and user_stats.total_requests % 10 == 0:
|
||||
user_stats.improve_reputation()
|
||||
|
||||
if self.log_all:
|
||||
logger.debug(
|
||||
f"Запрос разрешен. Всего: {user_stats.total_requests}, репутация: {user_stats.reputation:.2f}",
|
||||
log_type='ANTI_SPAM',
|
||||
user=user_str
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
|
||||
@@ -360,4 +360,5 @@ COMMANDS: Final[dict[str, list[str]]] = {
|
||||
"дщпы", "kjub", # раскладка
|
||||
"log", "l", "лог", # сокращения
|
||||
],
|
||||
"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ from urllib.parse import urlparse, ParseResult
|
||||
from typing import Optional, Any
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic import field_validator, model_validator, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from aiogram.types import ChatAdministratorRights
|
||||
|
||||
@@ -21,6 +21,7 @@ class _Settings(BaseSettings):
|
||||
# ============== ОСНОВНЫЕ ПАРАМЕТРЫ ==============
|
||||
# Токены бота
|
||||
BOT_TOKEN: Optional[str] = None
|
||||
DATABASE_PATH: Optional[str] = "data/banwords.db"
|
||||
|
||||
# Параметры сообщений
|
||||
PARSE_MODE: str = "HTML"
|
||||
@@ -61,6 +62,35 @@ class _Settings(BaseSettings):
|
||||
BOT_DESCRIPTION: Optional[str] = None
|
||||
BOT_SHORT_DESCRIPTION: Optional[str] = None
|
||||
|
||||
# ============ АВТОКОММЕНТАРИИ В КАНАЛЕ ============
|
||||
|
||||
AUTO_COMMENT_CHANNELS: str = Field(
|
||||
default="",
|
||||
description="ID каналов через запятую"
|
||||
)
|
||||
|
||||
AUTO_COMMENT_TEXT: str = Field(
|
||||
default="🔍 <b>Нужна помощь?</b>\n\nИспользуй наш сервис!",
|
||||
description="Текст по умолчанию (HTML)"
|
||||
)
|
||||
|
||||
AUTO_COMMENT_BUTTON_TEXT: str = Field(
|
||||
default="🌐 Искать в Google",
|
||||
description="Текст кнопки по умолчанию"
|
||||
)
|
||||
|
||||
AUTO_COMMENT_BUTTON_URL: str = Field(
|
||||
default="https://www.google.com",
|
||||
description="URL кнопки по умолчанию"
|
||||
)
|
||||
|
||||
AUTO_COMMENT_PHOTO_URL: str = Field(
|
||||
default="https://via.placeholder.com/800x600.png",
|
||||
description="URL фото по умолчанию"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Права администратора
|
||||
ANONYMOUS: bool = False
|
||||
MANAGE_CHAT: bool = True
|
||||
@@ -160,6 +190,25 @@ class _Settings(BaseSettings):
|
||||
return self
|
||||
|
||||
# ================= СВОЙСТВА =================
|
||||
@property
|
||||
def AUTO_COMMENT_CHANNELS_LIST(self) -> list[int]:
|
||||
"""Преобразует строку ID каналов в список"""
|
||||
if not self.AUTO_COMMENT_CHANNELS:
|
||||
return []
|
||||
|
||||
try:
|
||||
return [
|
||||
int(channel_id.strip())
|
||||
for channel_id in self.AUTO_COMMENT_CHANNELS.split(",")
|
||||
if channel_id.strip()
|
||||
]
|
||||
except ValueError:
|
||||
from middleware.loggers import logger # ✅ ДОБАВЬ ИМПОРТ
|
||||
logger.error(
|
||||
"Неверный формат AUTO_COMMENT_CHANNELS",
|
||||
log_type="CONFIG"
|
||||
)
|
||||
return []
|
||||
|
||||
@property
|
||||
def rights(self) -> ChatAdministratorRights:
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine
|
||||
)
|
||||
|
||||
from configs import settings
|
||||
from middleware.loggers import logger
|
||||
from .models import Base
|
||||
|
||||
@@ -26,7 +27,7 @@ class Database:
|
||||
session_factory: Фабрика сессий
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "banwords.db"):
|
||||
def __init__(self, db_path: str = settings.DATABASE_PATH):
|
||||
"""
|
||||
Args:
|
||||
db_path: Путь к SQLite файлу
|
||||
@@ -99,7 +100,7 @@ class Database:
|
||||
_db_instance: Database | None = None
|
||||
|
||||
|
||||
def get_db(db_path: str = "banwords.db") -> Database:
|
||||
def get_db(db_path: str = settings.DATABASE_PATH) -> Database:
|
||||
"""
|
||||
Возвращает глобальный экземпляр Database (Singleton).
|
||||
|
||||
|
||||
@@ -623,6 +623,98 @@ class BanWordsManager:
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
# === AUTO COMMENTS ===
|
||||
|
||||
async def get_auto_comment_settings(self, channel_id: int) -> dict:
|
||||
"""
|
||||
Получает настройки автокомментариев для канала.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
|
||||
Returns:
|
||||
dict: Настройки или значения по умолчанию
|
||||
"""
|
||||
from configs import settings
|
||||
|
||||
auto_comment = await self.repo.get_auto_comment(channel_id)
|
||||
|
||||
if auto_comment and auto_comment.is_enabled:
|
||||
return {
|
||||
'text': auto_comment.text,
|
||||
'button_text': auto_comment.button_text,
|
||||
'button_url': auto_comment.button_url,
|
||||
'photo_url': auto_comment.photo_url,
|
||||
'is_enabled': auto_comment.is_enabled,
|
||||
}
|
||||
|
||||
# Возвращаем настройки по умолчанию из .env
|
||||
return {
|
||||
'text': settings.AUTO_COMMENT_TEXT,
|
||||
'button_text': settings.AUTO_COMMENT_BUTTON_TEXT,
|
||||
'button_url': settings.AUTO_COMMENT_BUTTON_URL,
|
||||
'photo_url': settings.AUTO_COMMENT_PHOTO_URL,
|
||||
'is_enabled': False, # По умолчанию выключено
|
||||
}
|
||||
|
||||
async def save_auto_comment_settings(
|
||||
self,
|
||||
channel_id: int,
|
||||
text: str,
|
||||
button_text: str,
|
||||
button_url: str,
|
||||
photo_url: str,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""Сохраняет настройки автокомментариев"""
|
||||
return await self.repo.set_auto_comment(
|
||||
channel_id=channel_id,
|
||||
text=text,
|
||||
button_text=button_text,
|
||||
button_url=button_url,
|
||||
photo_url=photo_url,
|
||||
updated_by=updated_by,
|
||||
is_enabled=True
|
||||
)
|
||||
|
||||
async def update_auto_comment_text(
|
||||
self,
|
||||
channel_id: int,
|
||||
text: str,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""Обновляет текст автокомментария"""
|
||||
return await self.repo.update_auto_comment_field(
|
||||
channel_id, 'text', text, updated_by
|
||||
)
|
||||
|
||||
async def update_auto_comment_button(
|
||||
self,
|
||||
channel_id: int,
|
||||
button_text: str,
|
||||
button_url: str,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""Обновляет кнопку автокомментария"""
|
||||
success_text = await self.repo.update_auto_comment_field(
|
||||
channel_id, 'button_text', button_text, updated_by
|
||||
)
|
||||
success_url = await self.repo.update_auto_comment_field(
|
||||
channel_id, 'button_url', button_url, updated_by
|
||||
)
|
||||
return success_text and success_url
|
||||
|
||||
async def update_auto_comment_photo(
|
||||
self,
|
||||
channel_id: int,
|
||||
photo_url: str,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""Обновляет фото автокомментария"""
|
||||
return await self.repo.update_auto_comment_field(
|
||||
channel_id, 'photo_url', photo_url, updated_by
|
||||
)
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера
|
||||
_manager_instance: Optional[BanWordsManager] = None
|
||||
|
||||
@@ -19,6 +19,7 @@ __all__ = (
|
||||
"Setting",
|
||||
"SpamStat",
|
||||
"SpamLog",
|
||||
"AutoComment",
|
||||
)
|
||||
|
||||
|
||||
@@ -252,3 +253,44 @@ class SpamLog(Base):
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
class AutoComment(Base):
|
||||
"""
|
||||
Настройки автокомментариев для каналов.
|
||||
|
||||
Attributes:
|
||||
id: Уникальный ID
|
||||
channel_id: ID канала (-100...)
|
||||
text: Текст комментария (HTML)
|
||||
button_text: Текст кнопки
|
||||
button_url: URL кнопки
|
||||
photo_url: URL фото для preview
|
||||
is_enabled: Включены ли автокомментарии для этого канала
|
||||
created_at: Дата создания
|
||||
updated_at: Дата последнего обновления
|
||||
updated_by: ID админа, который последним изменил
|
||||
"""
|
||||
__tablename__ = "auto_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
button_text: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
button_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
photo_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_enabled: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False
|
||||
)
|
||||
updated_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Repository для работы с банвордами через SQLAlchemy ORM.
|
||||
"""
|
||||
from typing import Set, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, delete, func, and_
|
||||
|
||||
@@ -15,7 +15,8 @@ from .models import (
|
||||
Admin,
|
||||
Setting,
|
||||
SpamStat,
|
||||
BanWordType
|
||||
BanWordType,
|
||||
AutoComment
|
||||
)
|
||||
|
||||
__all__ = ("BanWordsRepository",)
|
||||
@@ -796,3 +797,252 @@ class BanWordsRepository:
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return {}
|
||||
|
||||
# === AUTO COMMENTS ===
|
||||
|
||||
async def get_auto_comment(self, channel_id: int) -> Optional['AutoComment']:
|
||||
"""
|
||||
Получает настройки автокомментариев для канала.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
|
||||
Returns:
|
||||
AutoComment или None
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения автокомментария: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return None
|
||||
|
||||
async def set_auto_comment(
|
||||
self,
|
||||
channel_id: int,
|
||||
text: str,
|
||||
button_text: str,
|
||||
button_url: str,
|
||||
photo_url: str,
|
||||
updated_by: Optional[int] = None,
|
||||
is_enabled: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Сохраняет или обновляет настройки автокомментариев.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
text: Текст комментария
|
||||
button_text: Текст кнопки
|
||||
button_url: URL кнопки
|
||||
photo_url: URL фото
|
||||
updated_by: ID админа
|
||||
is_enabled: Включены ли комментарии
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
"""
|
||||
try:
|
||||
async with self.db.get_session() as session:
|
||||
# Проверяем существование
|
||||
result = await session.execute(
|
||||
select(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||
)
|
||||
auto_comment = result.scalar_one_or_none()
|
||||
|
||||
if auto_comment:
|
||||
# Обновляем существующую
|
||||
auto_comment.text = text
|
||||
auto_comment.button_text = button_text
|
||||
auto_comment.button_url = button_url
|
||||
auto_comment.photo_url = photo_url
|
||||
auto_comment.is_enabled = is_enabled
|
||||
auto_comment.updated_by = updated_by
|
||||
auto_comment.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# Создаём новую
|
||||
auto_comment = AutoComment(
|
||||
channel_id=channel_id,
|
||||
text=text,
|
||||
button_text=button_text,
|
||||
button_url=button_url,
|
||||
photo_url=photo_url,
|
||||
is_enabled=is_enabled,
|
||||
updated_by=updated_by
|
||||
)
|
||||
session.add(auto_comment)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Автокомментарий для канала {channel_id} обновлён",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка сохранения автокомментария: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def update_auto_comment_field(
|
||||
self,
|
||||
channel_id: int,
|
||||
field: str,
|
||||
value: str,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Обновляет одно поле автокомментария.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
field: Имя поля (text, button_text, button_url, photo_url)
|
||||
value: Новое значение
|
||||
updated_by: ID админа
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
"""
|
||||
try:
|
||||
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||
)
|
||||
auto_comment = result.scalar_one_or_none()
|
||||
|
||||
if not auto_comment:
|
||||
return False
|
||||
|
||||
# Обновляем поле
|
||||
if hasattr(auto_comment, field):
|
||||
setattr(auto_comment, field, value)
|
||||
auto_comment.updated_by = updated_by
|
||||
auto_comment.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Поле '{field}' автокомментария для канала {channel_id} обновлено",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"Поле '{field}' не существует в AutoComment",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка обновления поля автокомментария: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def toggle_auto_comment(
|
||||
self,
|
||||
channel_id: int,
|
||||
is_enabled: bool,
|
||||
updated_by: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Включает/выключает автокомментарии для канала.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
is_enabled: True - включить, False - выключить
|
||||
updated_by: ID админа
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
"""
|
||||
try:
|
||||
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||
)
|
||||
auto_comment = result.scalar_one_or_none()
|
||||
|
||||
if not auto_comment:
|
||||
return False
|
||||
|
||||
auto_comment.is_enabled = is_enabled
|
||||
auto_comment.updated_by = updated_by
|
||||
auto_comment.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Автокомментарии для канала {channel_id} {'включены' if is_enabled else 'выключены'}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка переключения автокомментария: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
async def get_all_auto_comments(self) -> list['AutoComment']:
|
||||
"""
|
||||
Получает все настройки автокомментариев.
|
||||
|
||||
Returns:
|
||||
List[AutoComment]: Список всех автокомментариев
|
||||
"""
|
||||
try:
|
||||
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(select(AutoComment))
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка получения всех автокомментариев: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return []
|
||||
|
||||
async def delete_auto_comment(self, channel_id: int) -> bool:
|
||||
"""
|
||||
Удаляет настройки автокомментариев для канала.
|
||||
|
||||
Args:
|
||||
channel_id: ID канала
|
||||
|
||||
Returns:
|
||||
bool: True если удалено
|
||||
"""
|
||||
try:
|
||||
|
||||
async with self.db.get_session() as session:
|
||||
result = await session.execute(
|
||||
delete(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||
)
|
||||
await session.commit()
|
||||
deleted = result.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"Автокомментарий для канала {channel_id} удалён",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка удаления автокомментария: {e}",
|
||||
log_type="DATABASE"
|
||||
)
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user