Compare commits

...

2 Commits

Author SHA1 Message Date
4d1b8911b3 v1.2.0 2026-02-20 08:34:50 +07:00
5aca4e8438 v1.2.0 2026-02-20 03:12:47 +07:00
24 changed files with 2434 additions and 1361 deletions

View File

@@ -1,7 +1,9 @@
""" """
Автоматическая отправка комментариев под постами канала (через discussion group) Автоматическая отправка комментариев под постами канала (через discussion group)
+ меню настройки (FSM) + меню настройки (FSM)
+ полная диагностика + полная диагностика
+ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!)
ВАЖНО: ВАЖНО:
- Комментарии в Telegram — это reply в привязанной группе обсуждений. - Комментарии в Telegram — это reply в привязанной группе обсуждений.
@@ -11,7 +13,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Optional, Tuple, Dict from typing import Optional, Tuple, Dict, Any, List
from aiogram import Router, F, Bot from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
@@ -23,10 +25,11 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from configs import settings from configs import settings, COMMANDS
from database import get_manager from database import get_manager, AutoComment
from middleware.loggers import logger from middleware.loggers import logger
from bot.filters.admin import IsAdmin from bot.filters.admin import IsAdmin
from bot.utils import log_action, tg_emoji
__all__ = ("router",) __all__ = ("router",)
@@ -43,7 +46,7 @@ class CommentEditStates(StatesGroup):
waiting_button_text = State() waiting_button_text = State()
waiting_button_url = State() waiting_button_url = State()
waiting_photo_url = State() waiting_photo_url = State()
waiting_add_channel = State() # ✅ ДОБАВИЛИ
# ====================================================================== # ======================================================================
# HELPERS # HELPERS
@@ -58,25 +61,24 @@ def _defaults() -> dict:
"is_enabled": False, "is_enabled": False,
} }
def _render_menu_text(channel_id: int, config: dict) -> str:
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
async def get_channel_config(channel_id: int) -> dict: 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
"""
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
return (
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"💡 Выберите действие:"
)
def create_main_menu(channel_id: int) -> InlineKeyboardBuilder: def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
"""Создаёт главное меню управления автокомментариями""" """Создаёт главное меню управления автокомментариями"""
@@ -87,57 +89,50 @@ def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
ikb.button(text="👁 Предпросмотр", callback_data=f"edit:{channel_id}:preview") 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}:toggle")
ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic") 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="add_channel") # ✅ ДОБАВИЛИ
ikb.button(text="🗑 Удалить", callback_data=f"edit:{channel_id}:delete")
ikb.button(text="❌ Закрыть", callback_data="menu:close") ikb.button(text="❌ Закрыть", callback_data="menu:close")
ikb.adjust(2, 2, 2, 1, 1) ikb.adjust(2, 2, 2, 2, 1)
return ikb return ikb
def create_channels_menu(channels: List[int]) -> InlineKeyboardBuilder(): # ✅ List[int]
def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder:
"""Создаёт меню выбора канала""" """Создаёт меню выбора канала"""
ikb = InlineKeyboardBuilder() ikb = InlineKeyboardBuilder()
for channel_id in channels: for channel_id in channels:
ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}") ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}")
ikb.button(text=" Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ
ikb.button(text="❌ Закрыть", callback_data="menu:close") ikb.button(text="❌ Закрыть", callback_data="menu:close")
ikb.adjust(1) ikb.adjust(1)
return ikb return ikb
async def get_all_channels() -> List[int]: # ✅ ✅ ✅ ИСПРАВЛЕНО: async!
"""Получает ВСЕ каналы из БД"""
manager = get_manager()
return await manager.get_auto_comment_channels()
def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]: def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]:
full_text = hide_link(config["photo_url"]) + (config["text"] or "") photo_url = (config.get("photo_url") or "").strip()
text = config.get("text") or ""
full_text = (hide_link(photo_url) if photo_url else "") + text
keyboard = InlineKeyboardBuilder() keyboard = InlineKeyboardBuilder()
if config.get("button_text") and config.get("button_url"): if config.get("button_text") and config.get("button_url"):
keyboard.button(text=config["button_text"], url=config["button_url"]) keyboard.button(text=config["button_text"], url=config["button_url"])
return full_text, keyboard return full_text, keyboard
def _extract_origin_channel_id(message: Message) -> Optional[int]: 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: if not message.is_automatic_forward:
return None return None
if message.forward_from_chat and message.forward_from_chat.type == "channel": if message.forward_from_chat and message.forward_from_chat.type == "channel":
return message.forward_from_chat.id return message.forward_from_chat.id
return None return None
# Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id) # Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id)
_MEDIA_GROUP_SEEN: Dict[Tuple[int, str], float] = {} _MEDIA_GROUP_SEEN: Dict[tuple[int, str], float] = {}
_MEDIA_GROUP_TTL_SEC = 45.0 _MEDIA_GROUP_TTL_SEC = 45.0
def _media_group_should_skip(message: Message) -> bool: def _media_group_should_skip(message: Message) -> bool:
"""
Возвращает True если это повторная часть альбома и мы уже комментировали.
Ключ: (chat_id, media_group_id).
"""
if not message.media_group_id: if not message.media_group_id:
return False return False
@@ -145,7 +140,6 @@ def _media_group_should_skip(message: Message) -> bool:
key = (message.chat.id, str(message.media_group_id)) key = (message.chat.id, str(message.media_group_id))
last = _MEDIA_GROUP_SEEN.get(key) last = _MEDIA_GROUP_SEEN.get(key)
# чистка старых ключей (лениво)
if len(_MEDIA_GROUP_SEEN) > 500: if len(_MEDIA_GROUP_SEEN) > 500:
cutoff = now - _MEDIA_GROUP_TTL_SEC cutoff = now - _MEDIA_GROUP_TTL_SEC
for k, t in list(_MEDIA_GROUP_SEEN.items()): for k, t in list(_MEDIA_GROUP_SEEN.items()):
@@ -158,18 +152,72 @@ def _media_group_should_skip(message: Message) -> bool:
_MEDIA_GROUP_SEEN[key] = now _MEDIA_GROUP_SEEN[key] = now
return False return 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})
if "is_enabled" not in config:
merged["is_enabled"] = False
return merged
async def _persist_settings_preserve_enabled(
channel_id: int,
patch: dict,
updated_by: int,
) -> bool:
"""
Надёжное сохранение настроек:
- всегда делает "первичное сохранение" через save_auto_comment_settings (чтобы запись точно появилась)
- сохраняет старый is_enabled (если было выключено — выключаем обратно после сохранения)
"""
manager = get_manager()
raw = await manager.get_auto_comment_settings(channel_id) or {}
was_enabled = bool(raw.get("is_enabled", False))
merged = _defaults()
merged.update({k: v for k, v in raw.items() if v is not None})
merged.update({k: v for k, v in patch.items() if v is not None})
# save_auto_comment_settings у тебя уже используется при включении (значит умеет создавать запись)
success = await manager.save_auto_comment_settings(
channel_id=channel_id,
text=merged.get("text") or "",
button_text=merged.get("button_text") or "",
button_url=merged.get("button_url") or "",
photo_url=merged.get("photo_url") or "",
updated_by=updated_by,
)
if not success:
return False
# Если было выключено — сохраняем выключенным (на случай если save_* включает фичу)
if not was_enabled:
try:
await manager.repo.toggle_auto_comment(
channel_id=channel_id,
is_enabled=False,
updated_by=updated_by,
)
except Exception as e:
logger.warning(f"toggle_auto_comment failed (preserve disabled): {e}", log_type="CHANNEL")
return True
# ====================================================================== # ======================================================================
# CORE: AUTO COMMENTS (discussion group) # CORE: AUTO COMMENTS (discussion group) ✅ ФИКС #3
# ====================================================================== # ======================================================================
@router.message(F.is_automatic_forward) @router.message(F.is_automatic_forward)
async def auto_comment_from_discussion_forward(message: Message) -> None: async def auto_comment_from_discussion_forward(message: Message) -> None:
"""
Ловим пост канала, автоматически пересланный в привязанную группу обсуждений.
Комментарий отправляем reply на это сообщение => появляется "под постом".
"""
# 0) Дедуп альбомов
if _media_group_should_skip(message): if _media_group_should_skip(message):
logger.debug( logger.debug(
f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}", f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}",
@@ -183,7 +231,6 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL" log_type="CHANNEL"
) )
# 1) Канал-источник
channel_id = _extract_origin_channel_id(message) channel_id = _extract_origin_channel_id(message)
if not channel_id: if not channel_id:
logger.warning( logger.warning(
@@ -192,23 +239,17 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
) )
return return
# 2) Проверка списка каналов channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await!
channels = settings.AUTO_COMMENT_CHANNELS_LIST
if not channels: if not channels:
logger.warning("❌ AUTO_COMMENT_CHANNELS_LIST пуст — нечего обрабатывать", log_type="CHANNEL")
return return
if channel_id not in channels: if channel_id not in channels:
logger.debug(f"⏭ Channel {channel_id} not in configured list", log_type="CHANNEL")
return return
# 3) /test_comment (если админ запостил команду в канале — она тоже прилетит сюда автофорвардом)
is_test = False is_test = False
txt = message.text or message.caption or "" txt = message.text or message.caption or ""
if "/test_comment" in txt: if "/test_comment" in txt:
is_test = True is_test = True
# 4) Настройки и статус
try: try:
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
except Exception as e: except Exception as e:
@@ -219,22 +260,21 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL") logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL")
return return
# 5) Формируем и отправляем комментарий (reply в группе)
try: try:
full_text, keyboard = _build_comment_payload(config) full_text, keyboard = _build_comment_payload(config)
sent = await message.reply( sent = await message.reply(
text=full_text, text=full_text,
reply_markup=keyboard.as_markup(), reply_markup=keyboard.as_markup(),
parse_mode="HTML" parse_mode="HTML",
) )
logger.success( logger.success(
"✅ Comment sent (discussion reply)\n" f"✅ Comment sent (discussion reply)\n"
f" ├─ Origin channel: {channel_id}\n" f" ├─ Origin channel: {channel_id}\n"
f" ├─ Discussion chat: {message.chat.id}\n" f" ├─ Discussion chat: {message.chat.id}\n"
f" ├─ Forward msg id: {message.message_id}\n" f" ├─ Forward msg id: {message.message_id}\n"
f" ─ Comment msg id: {sent.message_id}\n" f" ─ Comment msg id: {sent.message_id}\n"
f" └─ Test mode: {is_test}", f" └─ Test mode: {is_test}",
log_type="CHANNEL" log_type="CHANNEL"
) )
@@ -253,19 +293,60 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL" log_type="CHANNEL"
) )
except Exception as e: except Exception as e:
logger.error( logger.error(f"❌ Unexpected error while sending comment: {e}", log_type="CHANNEL")
f"❌ Unexpected error while sending comment: {e}",
log_type="CHANNEL",
)
# ====================================================================== # ======================================================================
# DIAGNOSTICS # ✅ НОВЫЕ ХЕНДЛЕРЫ ДЛЯ ДОБАВЛЕНИЯ КАНАЛА
# ======================================================================
@router.callback_query(F.data == "add_channel", IsAdmin())
async def add_channel_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.update_data(action="add_channel")
await state.set_state(CommentEditStates.waiting_add_channel)
await callback.message.edit_text(
text=(
" <b>ДОБАВИТЬ КАНАЛ</b>\n\n"
"Отправьте ID канала (число с минусом):\n"
"<code>Пример: -1003876862007</code>\n\n"
"💡 @userinfobot для получения ID\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_add_channel, IsAdmin())
async def process_add_channel(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
try:
channel_id = int(message.text.strip())
if not str(channel_id).startswith('-'):
raise ValueError()
except ValueError:
await message.answer("❌ Неверный ID. Пример: <code>-1003876862007</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.add_auto_comment_channel(channel_id, message.from_user.id)
await state.clear()
if success:
await message.answer(f"✅ <b>Канал добавлен!</b>\n<code>{channel_id}</code>\n/redactcomment", parse_mode="HTML")
else:
await message.answer(f"❌ Канал <code>{channel_id}</code> уже существует!", parse_mode="HTML")
# ======================================================================
# DIAGNOSTICS ✅ ФИКС #2
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic"))
async def diagnostic_channel_callback(callback: CallbackQuery) -> None: async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
"""Запускает полную диагностику канала"""
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
bot: Bot = callback.bot bot: Bot = callback.bot
@@ -273,13 +354,11 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n" diagnostic_text = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
# 1) ENV settings channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await!
channels = settings.AUTO_COMMENT_CHANNELS_LIST
diagnostic_text += "1⃣ <b>Настройки:</b>\n" diagnostic_text += "1⃣ <b>Настройки:</b>\n"
diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: <code>{channels}</code>\n" diagnostic_text += f" ├─ Каналы из БД: <code>{channels}</code>\n"
diagnostic_text += f" └─ Канал в списке: {'' if channel_id in channels else ''}\n\n" diagnostic_text += f" └─ Канал в списке: {'' if channel_id in channels else ''}\n\n"
# 2) DB config
diagnostic_text += "2⃣ <b>База данных:</b>\n" diagnostic_text += "2⃣ <b>База данных:</b>\n"
try: try:
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
@@ -292,7 +371,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
config = None config = None
# 3) Bot status in channel
diagnostic_text += "3⃣ <b>Бот в канале:</b>\n" diagnostic_text += "3⃣ <b>Бот в канале:</b>\n"
try: try:
member = await bot.get_chat_member(channel_id, bot.id) member = await bot.get_chat_member(channel_id, bot.id)
@@ -313,7 +391,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e: except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# 4) Linked discussion group
diagnostic_text += "4⃣ <b>Привязанная группа обсуждений:</b>\n" diagnostic_text += "4⃣ <b>Привязанная группа обсуждений:</b>\n"
linked_chat_id = None linked_chat_id = None
try: try:
@@ -327,7 +404,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e: except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# 5) Bot status in discussion group
diagnostic_text += "5⃣ <b>Бот в группе обсуждений:</b>\n" diagnostic_text += "5⃣ <b>Бот в группе обсуждений:</b>\n"
if not linked_chat_id: if not linked_chat_id:
diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n" diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n"
@@ -340,7 +416,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
else: else:
diagnostic_text += " ├─ Присутствует: ❌\n" diagnostic_text += " ├─ Присутствует: ❌\n"
# can_send_messages бывает не у всех типов, поэтому hasattr
if hasattr(gmember, "can_send_messages"): if hasattr(gmember, "can_send_messages"):
diagnostic_text += f" └─ can_send_messages: {'' if gmember.can_send_messages else ''}\n\n" diagnostic_text += f" └─ can_send_messages: {'' if gmember.can_send_messages else ''}\n\n"
else: else:
@@ -350,33 +425,37 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e: except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n" diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
# Recommendations
diagnostic_text += "💡 <b>Что должно быть для работы:</b>\n" diagnostic_text += "💡 <b>Что должно быть для работы:</b>\n"
if channel_id not in channels: if channel_id not in channels:
diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n" diagnostic_text += " • Добавьте канал \n"
diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n" diagnostic_text += (
diagnostic_text += "Подключите discussion group к каналу\n" "Включите автокомментарии (🔄 Переключить)\n"
diagnostic_text += " • Дайте боту право писать в группе обсуждений\n" " • Подключите discussion group к каналу\n"
diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n" " • Дайте боту право писать в группе обсуждений\n"
" • Для теста: отправьте пост в канал или пост с /test_comment\n"
)
if callback.message:
await callback.message.answer(text=diagnostic_text, parse_mode="HTML") await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
# ====================================================================== # ======================================================================
# ADMIN UI: COMMAND + MENUS # ADMIN UI: COMMAND + MENUS ✅ ФИКС #1
# ====================================================================== # ======================================================================
@router.message(Command("redactcomment"), IsAdmin()) @router.callback_query(F.data.casefold() == "redactcomment", IsAdmin())
@router.message(Command(*COMMANDS["redactcomment"], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="START_COMMAND", log_args=True)
async def redact_comment_cmd(message: Message, state: FSMContext) -> None: async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
"""Открывает меню управления автокомментариями""" channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await!
channels = settings.AUTO_COMMENT_CHANNELS_LIST
await state.clear()
if not channels: if not channels:
await message.answer( await message.answer(
" <b>Каналы не настроены</b>\n\n" "📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
"Добавьте ID каналов в .env файл:\n" "🚫 <b>Каналы не настроены</b>\n\n"
"<code>AUTO_COMMENT_CHANNELS=-1003876862007</code>\n\n" "👆 <b> Добавить канал</b>",
"💡 Узнать ID канала: перешлите пост из канала боту @userinfobot", reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка
parse_mode="HTML" parse_mode="HTML"
) )
return return
@@ -385,33 +464,14 @@ async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
await show_channel_menu(message, channels[0]) await show_channel_menu(message, channels[0])
else: else:
await message.answer( await message.answer(
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n" "📢 <b>Выберите канал:</b>",
"Выберите канал для настройки:",
reply_markup=create_channels_menu(channels).as_markup(), reply_markup=create_channels_menu(channels).as_markup(),
parse_mode="HTML" parse_mode="HTML"
) )
async def show_channel_menu(message: Message, channel_id: int) -> None: async def show_channel_menu(message: Message, channel_id: int) -> None:
"""Показывает меню настроек для конкретного канала"""
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" output = _render_menu_text(channel_id, config)
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( await message.answer(
text=output, text=output,
@@ -419,30 +479,15 @@ async def show_channel_menu(message: Message, channel_id: int) -> None:
parse_mode="HTML" parse_mode="HTML"
) )
@router.callback_query(F.data.startswith("select_channel:")) @router.callback_query(F.data.startswith("select_channel:"))
async def select_channel_callback(callback: CallbackQuery) -> None: async def select_channel_callback(callback: CallbackQuery, state: FSMContext) -> None:
"""Обработка выбора канала из списка"""
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
await state.clear()
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" output = _render_menu_text(channel_id, config)
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"💡 Выберите действие:"
)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=output, text=output,
reply_markup=create_main_menu(channel_id).as_markup(), reply_markup=create_main_menu(channel_id).as_markup(),
@@ -450,18 +495,18 @@ async def select_channel_callback(callback: CallbackQuery) -> None:
) )
await callback.answer() await callback.answer()
# ====================================================================== # ======================================================================
# EDIT TEXT # EDIT TEXT
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):text")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):text"), IsAdmin())
async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None: async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id) await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_text) await state.set_state(CommentEditStates.waiting_text)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=( text=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n" "📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
@@ -473,23 +518,31 @@ async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None
) )
await callback.answer() await callback.answer()
@router.message(CommentEditStates.waiting_text, IsAdmin())
@router.message(CommentEditStates.waiting_text)
async def process_text_input(message: Message, state: FSMContext) -> None: async def process_text_input(message: Message, state: FSMContext) -> None:
if message.text == "/cancel": if (message.text or "").strip() == "/cancel":
await state.clear() await state.clear()
await message.answer("❌ Отменено") await message.answer("❌ Отменено")
return return
data = await state.get_data() data = await state.get_data()
channel_id = data.get("channel_id") channel_id = data.get("channel_id")
if not channel_id:
await state.clear()
await message.answer("Не выбран канал. Откройте меню заново: /redactcomment")
return
manager = get_manager() new_text = message.text or ""
success = await manager.update_auto_comment_text(
channel_id=channel_id, try:
text=message.text or "", success = await _persist_settings_preserve_enabled(
channel_id=int(channel_id),
patch={"text": new_text},
updated_by=message.from_user.id updated_by=message.from_user.id
) )
except Exception as e:
logger.error(f"update text failed: {e}", log_type="CHANNEL")
success = False
await state.clear() await state.clear()
@@ -500,21 +553,21 @@ async def process_text_input(message: Message, state: FSMContext) -> None:
) )
return return
await message.answer(f"✅ <b>Текст обновлён!</b>", parse_mode="HTML") await message.answer("✅ <b>Текст обновлён!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id) await show_channel_menu(message, int(channel_id))
# ====================================================================== # ======================================================================
# EDIT BUTTON # EDIT BUTTON
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):button")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):button"), IsAdmin())
async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None: async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id) await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_button_text) await state.set_state(CommentEditStates.waiting_button_text)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=( text=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n" "🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
@@ -525,10 +578,9 @@ async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> No
) )
await callback.answer() await callback.answer()
@router.message(CommentEditStates.waiting_button_text, IsAdmin())
@router.message(CommentEditStates.waiting_button_text)
async def process_button_text(message: Message, state: FSMContext) -> None: async def process_button_text(message: Message, state: FSMContext) -> None:
if message.text == "/cancel": if (message.text or "").strip() == "/cancel":
await state.clear() await state.clear()
await message.answer("❌ Отменено") await message.answer("❌ Отменено")
return return
@@ -538,22 +590,21 @@ async def process_button_text(message: Message, state: FSMContext) -> None:
await message.answer( await message.answer(
text=( text=(
f"✅ Текст кнопки: <b>{message.text}</b>\n\n" f"✅ Текст кнопки: <b>{(message.text or '').strip()}</b>\n\n"
f"<b>Шаг 2 из 2:</b> Отправьте URL кнопки\n\n" f"<b>Шаг 2 из 2:</b> Отправьте URL кнопки\n\n"
f"Для отмены: /cancel" f"Для отмены: /cancel"
), ),
parse_mode="HTML" parse_mode="HTML"
) )
@router.message(CommentEditStates.waiting_button_url, IsAdmin())
@router.message(CommentEditStates.waiting_button_url)
async def process_button_url(message: Message, state: FSMContext) -> None: async def process_button_url(message: Message, state: FSMContext) -> None:
if message.text == "/cancel": if (message.text or "").strip() == "/cancel":
await state.clear() await state.clear()
await message.answer("❌ Отменено") await message.answer("❌ Отменено")
return return
url = message.text or "" url = (message.text or "").strip()
if not url.startswith(("http://", "https://")): if not url.startswith(("http://", "https://")):
await message.answer( await message.answer(
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://", "❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
@@ -563,37 +614,44 @@ async def process_button_url(message: Message, state: FSMContext) -> None:
data = await state.get_data() data = await state.get_data()
channel_id = data.get("channel_id") channel_id = data.get("channel_id")
button_text = data.get("button_text") or "" button_text = (data.get("button_text") or "").strip()
manager = get_manager() if not channel_id:
success = await manager.update_auto_comment_button( await state.clear()
channel_id=channel_id, await message.answer("Не выбран канал. Откройте меню заново: /redactcomment")
button_text=button_text, return
button_url=url,
try:
success = await _persist_settings_preserve_enabled(
channel_id=int(channel_id),
patch={"button_text": button_text, "button_url": url},
updated_by=message.from_user.id updated_by=message.from_user.id
) )
except Exception as e:
logger.error(f"update button failed: {e}", log_type="CHANNEL")
success = False
await state.clear() await state.clear()
if not success: if not success:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML") await message.answer("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return return
await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML") await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id) await show_channel_menu(message, int(channel_id))
# ====================================================================== # ======================================================================
# EDIT PHOTO URL # EDIT PHOTO URL
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"), IsAdmin())
async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None: async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id) await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_photo_url) await state.set_state(CommentEditStates.waiting_photo_url)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=( text=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n" "🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
@@ -604,15 +662,14 @@ async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> Non
) )
await callback.answer() await callback.answer()
@router.message(CommentEditStates.waiting_photo_url, IsAdmin())
@router.message(CommentEditStates.waiting_photo_url)
async def process_photo_url(message: Message, state: FSMContext) -> None: async def process_photo_url(message: Message, state: FSMContext) -> None:
if message.text == "/cancel": if (message.text or "").strip() == "/cancel":
await state.clear() await state.clear()
await message.answer("❌ Отменено") await message.answer("❌ Отменено")
return return
url = message.text or "" url = (message.text or "").strip()
if not url.startswith(("http://", "https://")): if not url.startswith(("http://", "https://")):
await message.answer( await message.answer(
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://", "❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
@@ -622,35 +679,42 @@ async def process_photo_url(message: Message, state: FSMContext) -> None:
data = await state.get_data() data = await state.get_data()
channel_id = data.get("channel_id") channel_id = data.get("channel_id")
if not channel_id:
await state.clear()
await message.answer("Не выбран канал. Откройте меню заново: /redactcomment")
return
manager = get_manager() try:
success = await manager.update_auto_comment_photo( success = await _persist_settings_preserve_enabled(
channel_id=channel_id, channel_id=int(channel_id),
photo_url=url, patch={"photo_url": url},
updated_by=message.from_user.id updated_by=message.from_user.id
) )
except Exception as e:
logger.error(f"update photo failed: {e}", log_type="CHANNEL")
success = False
await state.clear() await state.clear()
if not success: if not success:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML") await message.answer("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return return
await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML") await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id) await show_channel_menu(message, int(channel_id))
# ====================================================================== # ======================================================================
# PREVIEW # PREVIEW
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):preview"), IsAdmin())
async def preview_comment_callback(callback: CallbackQuery) -> None: async def preview_comment_callback(callback: CallbackQuery) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
full_text, keyboard = _build_comment_payload(config) full_text, keyboard = _build_comment_payload(config)
if callback.message:
await callback.message.answer( await callback.message.answer(
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}", text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
reply_markup=keyboard.as_markup(), reply_markup=keyboard.as_markup(),
@@ -658,12 +722,11 @@ async def preview_comment_callback(callback: CallbackQuery) -> None:
) )
await callback.answer("✅ Превью отправлено") await callback.answer("✅ Превью отправлено")
# ====================================================================== # ======================================================================
# TOGGLE # TOGGLE
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"), IsAdmin())
async def toggle_comment_callback(callback: CallbackQuery) -> None: async def toggle_comment_callback(callback: CallbackQuery) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
@@ -695,37 +758,21 @@ async def toggle_comment_callback(callback: CallbackQuery) -> None:
await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True) await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True)
# Обновляем меню
config = await get_channel_config(channel_id) config = await get_channel_config(channel_id)
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено" output = _render_menu_text(channel_id, config)
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"💡 Выберите действие:"
)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=output, text=output,
reply_markup=create_main_menu(channel_id).as_markup(), reply_markup=create_main_menu(channel_id).as_markup(),
parse_mode="HTML" parse_mode="HTML"
) )
# ====================================================================== # ======================================================================
# DELETE SETTINGS # DELETE SETTINGS
# ====================================================================== # ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete")) @router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"), IsAdmin())
async def delete_comment_callback(callback: CallbackQuery) -> None: async def delete_comment_callback(callback: CallbackQuery) -> None:
channel_id = int(callback.data.split(":")[1]) channel_id = int(callback.data.split(":")[1])
@@ -737,6 +784,8 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
return return
await callback.answer("🗑 Настройки удалены", show_alert=True) await callback.answer("🗑 Настройки удалены", show_alert=True)
if callback.message:
await callback.message.edit_text( await callback.message.edit_text(
text=( text=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n" "🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
@@ -748,23 +797,3 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
) )
# ======================================================================
# CLOSE / CANCEL
# ======================================================================
@router.callback_query(F.data == "menu:close")
async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await callback.message.delete()
await callback.answer("❌ Меню закрыто")
@router.message(Command("cancel"))
async def cancel_handler(message: Message, state: FSMContext) -> None:
current_state = await state.get_state()
if current_state is None:
await message.answer("❌ Нечего отменять")
return
await state.clear()
await message.answer("✅ Действие отменено")

View File

@@ -11,6 +11,8 @@ from .admins import router as admin_router
from .notifications import router as notifications_router from .notifications import router as notifications_router
from .id import router as id_router from .id import router as id_router
from .emoji import router as emoji_router from .emoji import router as emoji_router
from .cancel import router as cancel_router
from .bot_settings import router as setting_router
# Настройка экспорта и роутера # Настройка экспорта и роутера
__all__ = ("router",) __all__ = ("router",)
@@ -19,6 +21,7 @@ router: Router = Router(name=__name__)
# Подключение роутеров # Подключение роутеров
router.include_routers( router.include_routers(
cancel_router,
notifications_router, notifications_router,
report_router, report_router,
admin_router, admin_router,
@@ -30,4 +33,5 @@ conflict_router,
stats_router, stats_router,
id_router, id_router,
emoji_router, emoji_router,
setting_router,
) )

View File

@@ -6,248 +6,197 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot # ← ДОБАВЬ ЭТОТ ИМПОРТ
from bot.filters.admin import IsSuperAdmin from bot.filters.admin import IsSuperAdmin
from configs import settings, COMMANDS from configs import settings, COMMANDS
from database import get_manager from database import get_manager
from middleware.loggers import logger from middleware.loggers import logger
from bot.utils.decorators import log_action from bot.utils import log_action, tg_emoji
__all__ = ("router",) __all__ = ("router",)
router: Router = Router(name="admin_management_router") router: Router = Router(name="admin_management_router")
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def parse_user_id(text: str, command: str) -> tuple[bool, str | int]: def parse_user_id(text: str, command: str) -> tuple[bool, str | int]:
"""
Парсит ID пользователя из команды.
Args:
text: Полный текст сообщения
command: Название команды
Returns:
(success, result): result это либо user_id (int), либо текст ошибки (str)
"""
parts = text.split(maxsplit=1) parts = text.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2:
return False, f" Использование: <code>/{command} <ID></code>" return False, f'{tg_emoji("4961187972822074653")} Использование: <code>/{command} <ID></code>'
user_id_str = parts[1].strip() user_id_str = parts[1].strip()
# Валидация ID
try: try:
user_id = int(user_id_str) user_id = int(user_id_str)
if user_id <= 0: if user_id <= 0:
return False, " ID должен быть положительным числом" return False, f'{tg_emoji("4961187972822074653")} ID должен быть положительным числом'
if user_id > 9999999999:
if user_id > 9999999999: # Максимальный Telegram ID return False, f'{tg_emoji("4961187972822074653")} Некорректный ID пользователя'
return False, "❌ Некорректный ID пользователя"
return True, user_id return True, user_id
except ValueError: except ValueError:
return False, " ID должен быть числом" return False, f'{tg_emoji("4961187972822074653")} ID должен быть числом'
async def get_user_display_name(user_id: int) -> str:
"""Получает имя пользователя или username или ID"""
try:
chat = await bot.get_chat(user_id)
name = f"{chat.first_name or ''} {chat.last_name or ''}".strip()
if name:
return name
if chat.username:
return f"@{chat.username}"
return str(user_id)
except:
return str(user_id)
def format_admin_info(user_id: int, username: str | None = None) -> str: def format_admin_info(user_id: int, username: str | None = None) -> str:
"""Форматирует информацию об админе"""
if username: if username:
return f"<code>{user_id}</code> (@{username})" return f'<code>{user_id}</code> (@{username})'
return f"<code>{user_id}</code>" return f'<code>{user_id}</code>'
def get_refresh_admins_kb(): def get_refresh_admins_kb():
"""Клавиатура для обновления списка админов"""
ikb = InlineKeyboardBuilder() ikb = InlineKeyboardBuilder()
ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh") ikb.button(text='🔄 Обновить', callback_data='listadmins:refresh')
ikb.button(text=" Добавить", callback_data="admin:help_add") ikb.button(text=' Добавить', callback_data='admin:help_add')
ikb.adjust(2) ikb.adjust(2)
return ikb.as_markup() return ikb.as_markup()
# ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА ================= # ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА =================
@router.message(Command(*COMMANDS.get("addadmin", ["addadmin"]), prefix=settings.PREFIX, ignore_case=True), @router.message(Command(*COMMANDS.get('addadmin', ['addadmin']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin()) IsSuperAdmin())
@log_action(action_name="ADD_ADMIN", log_args=True) @log_action(action_name='ADD_ADMIN', log_args=True)
async def add_admin_cmd(message: Message) -> None: async def add_admin_cmd(message: Message) -> None:
""" success, result = parse_user_id(message.text, 'addadmin')
Добавляет нового администратора бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /addadmin <ID>
Пример: /addadmin 123456789
"""
success, result = parse_user_id(message.text, "addadmin")
if not success: if not success:
await message.answer(result, parse_mode="HTML") await message.answer(result, parse_mode='HTML')
return return
user_id = result user_id = result
# Проверка: нельзя добавить самого себя
if user_id == message.from_user.id: if user_id == message.from_user.id:
await message.answer( await message.answer(
"⚠️ <b>Вы уже владелец бота</b>\n\n" f'{tg_emoji("4963024861615096794")} <b>Вы уже владелец бота</b>\n\n'
"Вам не нужно добавлять себя в администраторы", 'Вам не нужно добавлять себя в администраторы',
parse_mode="HTML" parse_mode='HTML'
) )
return return
# Проверка: нельзя добавить другого владельца
if user_id in settings.OWNER_ID: if user_id in settings.OWNER_ID:
await message.answer( await message.answer(
"⚠️ <b>Этот пользователь уже владелец бота</b>\n\n" f'{tg_emoji("4963024861615096794")} <b>Этот пользователь уже владелец бота</b>\n\n'
"Владельцы имеют полные права автоматически", 'Владельцы имеют полные права автоматически',
parse_mode="HTML" parse_mode='HTML'
) )
return return
manager = get_manager() manager = get_manager()
try: try:
# Проверяем, уже админ ли
is_already_admin = await manager.is_admin(user_id) is_already_admin = await manager.is_admin(user_id)
if is_already_admin: if is_already_admin:
display_name = await get_user_display_name(user_id)
await message.answer( await message.answer(
f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором", f'{tg_emoji("4963024861615096794")} Пользователь <b>{display_name}</b> уже является администратором',
parse_mode="HTML" parse_mode='HTML'
) )
return return
# Добавляем администратора added = await manager.add_admin(user_id=user_id, added_by=message.from_user.id)
added = await manager.add_admin(
user_id=user_id,
added_by=message.from_user.id
)
if added: if added:
display_name = await get_user_display_name(user_id)
text = ( text = (
f" <b>Администратор добавлен</b>\n\n" f'{tg_emoji("4963010134172239128")} <b>Администратор добавлен</b>\n\n'
f"👤 ID: {format_admin_info(user_id)}\n" f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n" f'{tg_emoji("4963343509533754468")} Имя: <b>{display_name}</b>\n'
f"📋 Права администратора:\n" f'{tg_emoji("4963343509533754468")} Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
f"├─ Управление банвордами\n" f'{tg_emoji("4961106084975608869")} Права администратора:\n'
f"├─ Просмотр статистики\n" f'├─ Управление банвордами\n'
f"├─ Активация режимов модерации\n" f'├─ Просмотр статистики\n'
f"└─ Все команды бота\n\n" f'├─ Активация режимов модерации\n'
f"⚠️ <i>Не может управлять другими админами</i>\n" f'└─ Все команды бота\n\n'
f"Список админов: /listadmins" f'{tg_emoji("4963024861615096794")} <i>Не может управлять другими админами</i>\n'
) f'Список админов: <b>/listadmins</b>'
logger.info(
f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
log_type="ADMIN_MGMT"
) )
logger.info(f'Администратор добавлен: {user_id} (добавил: {message.from_user.id})', log_type='ADMIN_MGMT')
else: else:
text = " <b>Ошибка добавления администратора</b>\n\nПопробуйте позже" text = f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления администратора</b>\n\nПопробуйте позже'
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="ADMIN_MGMT") logger.error(f'Ошибка добавления администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(" <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML") await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления</b>\n\nПопробуйте позже', parse_mode='HTML')
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА ================= # ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True), @router.message(Command(*COMMANDS.get('remadmin', ['remadmin']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin()) IsSuperAdmin())
@log_action(action_name="REMOVE_ADMIN", log_args=True) @log_action(action_name='REMOVE_ADMIN', log_args=True)
async def remove_admin_cmd(message: Message) -> None: async def remove_admin_cmd(message: Message) -> None:
""" success, result = parse_user_id(message.text, 'remadmin')
Удаляет администратора бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /remadmin <ID>
Пример: /remadmin 123456789
"""
success, result = parse_user_id(message.text, "remadmin")
if not success: if not success:
await message.answer(result, parse_mode="HTML") await message.answer(result, parse_mode='HTML')
return return
user_id = result user_id = result
# Проверка: нельзя удалить владельца
if user_id in settings.OWNER_ID: if user_id in settings.OWNER_ID:
await message.answer( await message.answer(
"⚠️ <b>Нельзя удалить владельца</b>\n\n" f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить владельца</b>\n\n'
"Владельцы имеют права постоянно", 'Владельцы имеют права постоянно',
parse_mode="HTML" parse_mode='HTML'
) )
return return
# Проверка: нельзя удалить самого себя (если вы владелец)
if user_id == message.from_user.id: if user_id == message.from_user.id:
await message.answer( await message.answer(
"⚠️ <b>Нельзя удалить самого себя</b>", f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить самого себя</b>',
parse_mode="HTML" parse_mode='HTML'
) )
return return
manager = get_manager() manager = get_manager()
try: try:
# Проверяем, является ли администратором
is_admin = await manager.is_admin(user_id) is_admin = await manager.is_admin(user_id)
if not is_admin: if not is_admin:
display_name = await get_user_display_name(user_id)
await message.answer( await message.answer(
f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором", f'{tg_emoji("4963024861615096794")} Пользователь <b>{display_name}</b> не является администратором',
parse_mode="HTML" parse_mode='HTML'
) )
return return
# Удаляем администратора
removed = await manager.remove_admin(user_id=user_id) removed = await manager.remove_admin(user_id=user_id)
if removed: if removed:
display_name = await get_user_display_name(user_id)
text = ( text = (
f"🗑 <b>Администратор удалён</b>\n\n" f'🗑 <b>Администратор удалён</b>\n\n'
f"👤 ID: {format_admin_info(user_id)}\n" f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n" f'{tg_emoji("4961064956368782417")} Имя: <b>{display_name}</b>\n'
f"⚠️ <i>Пользователь больше не имеет доступа к командам бота</i>" f'{tg_emoji("4963343509533754468")} Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
) f'{tg_emoji("4963024861615096794")} <i>Пользователь больше не имеет доступа к командам бота</i>'
logger.info(
f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
log_type="ADMIN_MGMT"
) )
logger.info(f'Администратор удалён: {user_id} (удалил: {message.from_user.id})', log_type='ADMIN_MGMT')
else: else:
text = " <b>Ошибка удаления администратора</b>\n\nПопробуйте позже" text = f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления администратора</b>\n\nПопробуйте позже'
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="ADMIN_MGMT") logger.error(f'Ошибка удаления администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(" <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML") await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления</b>\n\nПопробуйте позже', parse_mode='HTML')
# ================= СПИСОК АДМИНИСТРАТОРОВ ================= # ================= СПИСОК АДМИНИСТРАТОРОВ =================
@router.callback_query(F.data == "listadmins:refresh") @router.callback_query(F.data == 'listadmins:refresh')
@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True), @router.message(Command(*COMMANDS.get('listadmins', ['listadmins']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin()) IsSuperAdmin())
@log_action(action_name="LIST_ADMINS") @log_action(action_name='LIST_ADMINS')
async def list_admins_cmd(update: Message | CallbackQuery) -> None: async def list_admins_cmd(update: Message | CallbackQuery) -> None:
"""
Показывает список всех администраторов бота.
Доступно только владельцам бота (OWNER_ID).
Использование: /listadmins
"""
# Определяем тип update
if isinstance(update, CallbackQuery): if isinstance(update, CallbackQuery):
message = update.message message = update.message
is_callback = True is_callback = True
@@ -256,179 +205,143 @@ async def list_admins_cmd(update: Message | CallbackQuery) -> None:
is_callback = False is_callback = False
manager = get_manager() manager = get_manager()
try: try:
# Получаем всех админов из БД
db_admins = await manager.repo.get_admins() db_admins = await manager.repo.get_admins()
# Получаем статистику output = f'{tg_emoji("4960891456869893259")} <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n'
stats = await manager.get_stats()
# === ФОРМИРУЕМ ВЫВОД === # ВЛАДЕЛЬЦЫ
output += f'{tg_emoji("4963343509533754468")} <b>Владельцы бота</b> (полные права):\n'
output = "👥 <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n"
# Владельцы (OWNER_ID)
output += "👑 <b>Владельцы бота</b> (полные права):\n"
for owner_id in settings.OWNER_ID: for owner_id in settings.OWNER_ID:
output += f'├─ <a href="tg://user?id={owner_id}">{owner_id}</a>\n' display_name = await get_user_display_name(owner_id)
output += "\n" output += f'├─ <a href="tg://user?id={owner_id}">{display_name}</a>\n'
output += '\n'
# Администраторы из БД # АДМИНИСТРАТОРЫ
if db_admins: if db_admins:
output += f"⚙️ <b>Администраторы</b> ({len(db_admins)}):\n" output += f'{tg_emoji("4961064956368782417")} <b>Администраторы</b> ({len(db_admins)}):\n'
for admin_id in sorted(db_admins): for admin_id in sorted(db_admins):
output += f'├─ <a href="tg://user?id={admin_id}">{admin_id}</a>\n' display_name = await get_user_display_name(admin_id)
output += f'├─ <a href="tg://user?id={admin_id}">{display_name}</a>\n'
output += "\n" output += '\n'
output += "📋 <b>Права администраторов:</b>\n" output += f'{tg_emoji("4961106084975608869")} <b>Права администраторов:</b>\n'
output += "├─ Управление банвордами\n" output += '├─ Управление банвордами\n'
output += "├─ Просмотр статистики\n" output += '├─ Просмотр статистики\n'
output += "├─ Активация режимов модерации\n" output += '├─ Активация режимов модерации\n'
output += "└─ Все команды бота (кроме управления админами)\n\n" output += '└─ Все команды бота\n\n'
else: else:
output += "⚙️ <b>Администраторы:</b>\n" output += f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
output += "└─ <i>Нет дополнительных администраторов</i>\n\n" output += '└─ <i>Нет дополнительных администраторов</i>\n\n'
# Общая статистика
total_admins = len(settings.OWNER_ID) + len(db_admins) total_admins = len(settings.OWNER_ID) + len(db_admins)
output += f"📊 <b>Итого:</b> {total_admins} администратор(ов)\n\n" output += f'{tg_emoji("4961061266991875258")} <b>Итого:</b> {total_admins} администратор(ов)\n\n'
# Команды управления output += f'{tg_emoji("4961027057577362562")} <b>Управление:</b>\n'
output += "🔧 <b>Управление:</b>\n" output += '• <b>/adminhelp</b> — помощь по командам админов\n'
output += "/addadmin <code>ID</code> — добавить админа\n" output += '• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
output += "/remadmin <code>ID</code> — удалить админа\n\n" output += '• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
output += f'{tg_emoji("4961186405159011104")} <i>Только владельцы могут управлять администраторами</i>'
output += "💡 <i>Только владельцы могут управлять администраторами</i>"
# Клавиатура
keyboard = get_refresh_admins_kb() keyboard = get_refresh_admins_kb()
# Отправка
if is_callback: if is_callback:
await message.edit_text( await message.edit_text(text=output, parse_mode='HTML', reply_markup=keyboard)
text=output, await update.answer(f'{tg_emoji("4963010134172239128")} Список обновлён')
parse_mode="HTML",
reply_markup=keyboard
)
await update.answer("✅ Список обновлён")
else: else:
await message.answer( await message.answer(text=output, parse_mode='HTML', reply_markup=keyboard)
text=output,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e: except Exception as e:
logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT") logger.error(f'Ошибка получения списка администраторов: {e}', log_type='ADMIN_MGMT')
error_text = f'{tg_emoji("4961187972822074653")} <b>Ошибка загрузки списка</b>\n\nПопробуйте позже'
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
if is_callback: if is_callback:
await update.answer(" Ошибка загрузки", show_alert=True) await update.answer(f'{tg_emoji("4961187972822074653")} Ошибка загрузки', show_alert=True)
else: else:
await message.answer(error_text, parse_mode="HTML") await message.answer(error_text, parse_mode='HTML')
# ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK ================= # ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK =================
@router.callback_query(F.data == "admin:help_add") @router.callback_query(F.data == 'admin:help_add')
async def admin_help_add_callback(callback: CallbackQuery) -> None: async def admin_help_add_callback(callback: CallbackQuery) -> None:
"""Показывает помощь по добавлению админа"""
text = ( text = (
" <b>Как добавить администратора?</b>\n\n" f'{tg_emoji("4963469772982322370")} <b>Как добавить администратора?</b>\n\n'
"1 Узнайте Telegram ID пользователя\n" f'{tg_emoji("4960889107522782272")} Узнайте Telegram ID пользователя\n'
" • Используйте бота @userinfobot\n" ' • Используйте команду <b>/id</b> или бота @userinfobot\n'
" • Или попросите пользователя написать /start\n\n" f'{tg_emoji("4960889107522782272")} Выполните команду:\n'
"2⃣ Выполните команду:\n" ' <code>/addadmin ID</code>\n\n'
" <code>/addadmin ID</code>\n\n" 'Пример:\n'
"Пример:\n" '<code>/addadmin 123456789</code>'
"<code>/addadmin 123456789</code>"
) )
await callback.answer() await callback.answer()
await callback.message.answer(text, parse_mode="HTML") await callback.message.answer(text, parse_mode='HTML')
@router.message(Command(*COMMANDS.get("adminhelp", ["adminhelp"]), prefix=settings.PREFIX, ignore_case=True), @router.message(Command(*COMMANDS.get('adminhelp', ['adminhelp']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin()) IsSuperAdmin())
async def admin_help_cmd(message: Message) -> None: async def admin_help_cmd(message: Message) -> None:
"""
Показывает подробную справку по управлению администраторами.
Использование: /adminhelp
"""
text = ( text = (
"👥 <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n" f'{tg_emoji("4960891456869893259")} <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n'
"🔐 <b>Уровни доступа:</b>\n\n" f'{tg_emoji("4963401727815451692")} <b>Уровни доступа:</b>\n\n'
"👑 <b>Владельцы</b> (OWNER_ID):\n" f'{tg_emoji("4963343509533754468")} <b>Владельцы</b> (OWNER_ID):\n'
"├─ Все права администратора\n" '├─ Все права администратора\n'
"├─ Управление другими админами\n" '├─ Управление другими админами\n'
"└─ Указываются в конфигурации\n\n" '└─ Указываются в конфигурации\n\n'
"⚙️ <b>Администраторы:</b>\n" f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
"├─ Управление банвордами\n" '├─ Управление банвордами\n'
"├─ Просмотр статистики\n" '├─ Просмотр статистики\n'
"├─ Активация режимов модерации\n" '├─ Активация режимов модерации\n'
"└─ НЕ могут управлять админами\n\n" '└─ Не могут управлять админами\n\n'
"📝 <b>Команды:</b>\n" f'{tg_emoji("4963241130398319816")} <b>Команды:</b>\n'
"• /listadmins — список всех админов\n" '• <b>/adminhelp</b> — помощь по командам админов\n'
"• /addadmin <code>ID</code> — добавить админа\n" '• <b>/listadmins</b> — список всех админов\n'
"• /remadmin <code>ID</code> — удалить админа\n\n" '• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
"💡 <b>Как узнать ID пользователя?</b>\n" '• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
"• Используйте бота @userinfobot\n" f'{tg_emoji("4961186405159011104")} <b>Как узнать ID пользователя?</b>\n'
"• Попросите пользователя написать боту\n" '• Используйте команду <b>/id</b> или бота @userinfobot\n'
"• ID отображается в логах бота\n\n" '• Или попросите пользователя написать боту\n'
"⚠️ <b>Важно:</b>\n" '• ID отображается в логах бота\n\n'
"├─ Нельзя удалить владельца\n" f'{tg_emoji("4963024861615096794")} <b>Важно:</b>\n'
"├─ Нельзя удалить самого себя\n" '├─ Нельзя удалить владельца\n'
"└─ Все действия логируются" '├─ Нельзя удалить самого себя\n'
'└─ Все действия логируются'
) )
await message.answer(text, parse_mode='HTML')
await message.answer(text, parse_mode="HTML")
@router.message(Command(*COMMANDS.get("checkadmin", ["checkadmin"]), prefix=settings.PREFIX, ignore_case=True), @router.message(Command(*COMMANDS.get('checkadmin', ['checkadmin']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin()) IsSuperAdmin())
@log_action(action_name="CHECK_ADMIN") @log_action(action_name='CHECK_ADMIN')
async def check_admin_cmd(message: Message) -> None: async def check_admin_cmd(message: Message) -> None:
""" success, result = parse_user_id(message.text, 'checkadmin')
Проверяет, является ли пользователь администратором.
Использование: /checkadmin <ID>
"""
success, result = parse_user_id(message.text, "checkadmin")
if not success: if not success:
await message.answer(result, parse_mode="HTML") await message.answer(result, parse_mode='HTML')
return return
user_id = result user_id = result
manager = get_manager() manager = get_manager()
try: try:
# Проверяем статус
is_owner = user_id in settings.OWNER_ID is_owner = user_id in settings.OWNER_ID
is_db_admin = await manager.is_admin(user_id) is_db_admin = await manager.is_admin(user_id)
text = f"🔍 <b>Проверка пользователя</b>\n\n" text = f'{tg_emoji("4961092195051373778")} <b>Проверка пользователя</b>\n\n'
text += f"👤 ID: <code>{user_id}</code>\n\n" text += f'{tg_emoji("4961064956368782417")} ID: <code>{user_id}</code>\n\n'
if is_owner: if is_owner:
text += "👑 Статус: <b>Владелец бота</b>\n" text += f'{tg_emoji("4963343509533754468")} Статус: <b>Владелец бота</b>\n'
text += " Полные права администратора\n" text += f'{tg_emoji("4963010134172239128")} Полные права администратора\n'
text += " Может управлять админами" text += f'{tg_emoji("4963010134172239128")} Может управлять админами'
elif is_db_admin: elif is_db_admin:
text += "⚙️ Статус: <b>Администратор</b>\n" text += f'{tg_emoji("4961064956368782417")} Статус: <b>Администратор</b>\n'
text += " Доступ к командам бота\n" text += f'{tg_emoji("4963010134172239128")} Доступ к командам бота\n'
text += " Не может управлять админами" text += f'{tg_emoji("4961187972822074653")} Не может управлять админами'
else: else:
text += "👤 Статус: <b>Обычный пользователь</b>\n" text += f'{tg_emoji("4961064956368782417")} Статус: <b>Обычный пользователь</b>\n'
text += " Нет прав администратора\n\n" text += f'{tg_emoji("4961187972822074653")} Нет прав администратора\n\n'
text += f"Добавить в админы: <code>/addadmin {user_id}</code>" text += f'Добавить в админы: <code>/addadmin {user_id}</code>'
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="ADMIN_MGMT") logger.error(f'Ошибка проверки администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(" <b>Ошибка проверки</b>", parse_mode="HTML") await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка проверки</b>', parse_mode='HTML')

View File

@@ -0,0 +1,330 @@
"""
Команда /settings - управление настройками БЕЗ .env
ADMIN_CHAT_ID, ADMIN_THREAD_ID, REPORT_CHAT_ID, REPORT_THREAD_ID
"""
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.exceptions import TelegramBadRequest
from middleware.loggers import logger
from bot.filters.admin import IsAdmin
from database import get_manager
__all__ = ("router",)
router: Router = Router(name="bot_settings_router")
# ======================================================================
# FSM STATES
# ======================================================================
class BotSettingsStates(StatesGroup):
"""Состояния для редактирования настроек бота"""
waiting_admin_chat = State()
waiting_admin_thread = State()
waiting_report_chat = State()
waiting_report_thread = State()
# ======================================================================
# MAIN MENU
# ======================================================================
def _format_chat_id(chat_id: str | None) -> str:
"""Форматирует ID чата для отображения"""
if chat_id is None:
return "Не установлен"
return f"✅ <code>{chat_id}</code>"
def create_settings_menu() -> InlineKeyboardBuilder:
"""Главное меню настроек"""
ikb = InlineKeyboardBuilder()
ikb.button(text="📢 Админ-чат", callback_data="settings:admin_chat")
ikb.button(text="🧵 Топик админ-чата", callback_data="settings:admin_thread")
ikb.button(text="📊 Чат репортов", callback_data="settings:report_chat")
ikb.button(text="🧵 Топик репортов", callback_data="settings:report_thread")
ikb.button(text="🔄 Обновить", callback_data="settings:refresh")
ikb.button(text="❌ Закрыть", callback_data="settings:close")
ikb.adjust(2)
return ikb
def cancel_keyboard():
"""Клавиатура с кнопкой 'Назад' для окон ввода"""
ikb = InlineKeyboardBuilder()
ikb.button(text="◀️ Назад", callback_data="settings:cancel")
return ikb.as_markup()
# ======================================================================
# MAIN HANDLER
# ======================================================================
@router.message(Command("settings"), IsAdmin())
async def settings_cmd(message: Message, state: FSMContext) -> None:
"""Главная команда /settings"""
await state.clear()
await show_settings_menu(message)
async def show_settings_menu(message_or_callback: Message | CallbackQuery) -> None:
"""Показывает меню настроек (отправляет новое сообщение или редактирует существующее)"""
manager = get_manager()
current = await manager.get_bot_settings()
text = (
"⚙️ <b>НАСТРОЙКИ БОТА</b>\n\n"
"📢 <b>Админ-чат:</b> " + _format_chat_id(current.get('admin_chat_id')) + "\n"
"🧵 <b>Топик админ:</b> " + _format_chat_id(current.get('admin_thread_id')) + "\n\n"
"📊 <b>Чат репортов:</b> " + _format_chat_id(current.get('report_chat_id')) + "\n"
"🧵 <b>Топик репортов:</b> " + _format_chat_id(current.get('report_thread_id')) + "\n\n"
"💡 Используйте @userinfobot для получения ID чатов\n"
"💡 Для топиков: ID из сообщения в топике"
)
markup = create_settings_menu().as_markup()
if isinstance(message_or_callback, Message):
await message_or_callback.answer(text, reply_markup=markup, parse_mode="HTML")
else:
try:
await message_or_callback.message.edit_text(text, reply_markup=markup, parse_mode="HTML")
except TelegramBadRequest as e:
if "message is not modified" in str(e):
await message_or_callback.answer("🔄 Нет изменений")
else:
raise
# ======================================================================
# CALLBACK HANDLERS
# ======================================================================
@router.callback_query(F.data == "settings:refresh")
async def refresh_settings(callback: CallbackQuery, state: FSMContext) -> None:
"""Обновляет меню (с защитой от MessageNotModified)"""
await show_settings_menu(callback)
@router.callback_query(F.data == "settings:close")
async def close_settings(callback: CallbackQuery, state: FSMContext) -> None:
"""Закрывает меню"""
await state.clear()
try:
await callback.message.delete()
except:
pass
await callback.answer("❌ Закрыто")
@router.callback_query(F.data == "settings:cancel")
async def cancel_edit(callback: CallbackQuery, state: FSMContext) -> None:
"""Возврат в главное меню без сохранения"""
await state.clear()
await show_settings_menu(callback)
@router.callback_query(F.data == "settings:admin_chat")
async def edit_admin_chat(callback: CallbackQuery, state: FSMContext) -> None:
"""Редактирование админ-чата"""
await state.set_state(BotSettingsStates.waiting_admin_chat)
await callback.message.edit_text(
"📢 <b>АДМИН-ЧАТ</b>\n\n"
"Отправьте ID чата для уведомлений:\n"
"<code>Пример: -1003764219200</code>\n\n"
"Для отключения: <code>null</code>\n\n"
"Или нажмите кнопку ниже для возврата в меню.",
parse_mode="HTML",
reply_markup=cancel_keyboard()
)
await callback.answer()
@router.callback_query(F.data == "settings:admin_thread")
async def edit_admin_thread(callback: CallbackQuery, state: FSMContext) -> None:
"""Редактирование топика админ-чата"""
await state.set_state(BotSettingsStates.waiting_admin_thread)
await callback.message.edit_text(
"🧵 <b>ТОПИК АДМИН-ЧАТА</b>\n\n"
"Отправьте ID топика:\n"
"<code>Пример: 1</code>\n\n"
"Для отключения: <code>null</code>\n\n"
"Или нажмите кнопку ниже для возврата в меню.",
parse_mode="HTML",
reply_markup=cancel_keyboard()
)
await callback.answer()
@router.callback_query(F.data == "settings:report_chat")
async def edit_report_chat(callback: CallbackQuery, state: FSMContext) -> None:
"""Редактирование чата репортов"""
await state.set_state(BotSettingsStates.waiting_report_chat)
await callback.message.edit_text(
"📊 <b>ЧАТ РЕПОРТОВ</b>\n\n"
"Отправьте ID чата для репортов:\n"
"<code>Пример: -1003764219200</code>\n\n"
"Для отключения: <code>null</code>\n\n"
"Или нажмите кнопку ниже для возврата в меню.",
parse_mode="HTML",
reply_markup=cancel_keyboard()
)
await callback.answer()
@router.callback_query(F.data == "settings:report_thread")
async def edit_report_thread(callback: CallbackQuery, state: FSMContext) -> None:
"""Редактирование топика репортов"""
await state.set_state(BotSettingsStates.waiting_report_thread)
await callback.message.edit_text(
"🧵 <b>ТОПИК РЕПОРТОВ</b>\n\n"
"Отправьте ID топика:\n"
"<code>Пример: 1</code>\n\n"
"Для отключения: <code>null</code>\n\n"
"Или нажмите кнопку ниже для возврата в меню.",
parse_mode="HTML",
reply_markup=cancel_keyboard()
)
await callback.answer()
# ======================================================================
# MESSAGE HANDLERS (FSM)
# ======================================================================
@router.message(BotSettingsStates.waiting_admin_chat, IsAdmin())
async def process_admin_chat(message: Message, state: FSMContext) -> None:
text = message.text.strip()
if text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
if text == "null":
value = None
else:
try:
value = int(text)
if not str(value).startswith('-'):
raise ValueError("ID чата должен начинаться с минуса")
except ValueError:
await message.answer("❌ Неверный формат. Пример: <code>-1003764219200</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.set_bot_setting("admin_chat_id", str(value) if value else None)
await state.clear()
if success:
# Показываем обновлённое главное меню
await show_settings_menu(message)
# Удаляем сообщение с вводом
try:
await message.delete()
except:
pass
else:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
@router.message(BotSettingsStates.waiting_admin_thread, IsAdmin())
async def process_admin_thread(message: Message, state: FSMContext) -> None:
text = message.text.strip()
if text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
if text == "null":
value = None
else:
try:
value = int(text)
if value < 1:
raise ValueError("ID топика должен быть > 0")
except ValueError:
await message.answer("❌ Неверный формат. Пример: <code>1</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.set_bot_setting("admin_thread_id", str(value) if value else None)
await state.clear()
if success:
await show_settings_menu(message)
try:
await message.delete()
except:
pass
else:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
@router.message(BotSettingsStates.waiting_report_chat, IsAdmin())
async def process_report_chat(message: Message, state: FSMContext) -> None:
text = message.text.strip()
if text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
if text == "null":
value = None
else:
try:
value = int(text)
if not str(value).startswith('-'):
raise ValueError("ID чата должен начинаться с минуса")
except ValueError:
await message.answer("❌ Неверный формат. Пример: <code>-1003764219200</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.set_bot_setting("report_chat_id", str(value) if value else None)
await state.clear()
if success:
await show_settings_menu(message)
try:
await message.delete()
except:
pass
else:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
@router.message(BotSettingsStates.waiting_report_thread, IsAdmin())
async def process_report_thread(message: Message, state: FSMContext) -> None:
text = message.text.strip()
if text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
if text == "null":
value = None
else:
try:
value = int(text)
if value < 1:
raise ValueError("ID топика должен быть > 0")
except ValueError:
await message.answer("❌ Неверный формат. Пример: <code>1</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.set_bot_setting("report_thread_id", str(value) if value else None)
await state.clear()
if success:
await show_settings_menu(message)
try:
await message.delete()
except:
pass
else:
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
@router.message(Command("cancel"))
async def cancel_settings(message: Message, state: FSMContext) -> None:
"""Глобальный cancel"""
await state.clear()
await message.answer("✅ Настройки отменены")

View File

@@ -0,0 +1,48 @@
# ======================================================================
# CLOSE / CANCEL
# ======================================================================
from __future__ import annotations
from typing import Optional, Tuple, Dict, Any, List
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, COMMANDS
from database import get_manager, AutoComment
from middleware.loggers import logger
from bot.filters.admin import IsAdmin
from bot.utils import log_action, tg_emoji
__all__ = ("router",)
CMD: str = "cancel"
router: Router = Router(name="channel_comments_router")
@router.callback_query(F.data == "menu:close")
async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
try:
if callback.message:
await callback.message.delete()
except TelegramBadRequest:
pass
await callback.answer("❌ Меню закрыто")
@router.callback_query(F.data.casefold() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="START_COMMAND", log_args=True)
async def cancel_handler(message: Message, state: FSMContext) -> None:
current_state = await state.get_state()
if current_state is None:
await message.answer("❌ Нечего отменять")
return
await state.clear()
await message.answer("✅ Действие отменено")

View File

@@ -14,23 +14,28 @@ __all__ = ("router",)
router: Router = Router(name="emoji_extractor_router") router: Router = Router(name="emoji_extractor_router")
MAX_MSG_LEN = 3800 # Безопасный лимит (4096 - запас)
SEPARATOR = "\n" + "" * 30 + "\n\n"
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def _utf16_slice(text: str, offset: int, length: int) -> str:
"""
Корректно извлекает подстроку с учётом UTF-16 смещений Telegram API.
Telegram передаёт offset/length в UTF-16 code units, а не в Unicode codepoints.
"""
encoded = text.encode("utf-16-le")
return encoded[offset * 2 : (offset + length) * 2].decode("utf-16-le")
def extract_custom_emojis(message: Message) -> list[dict]: def extract_custom_emojis(message: Message) -> list[dict]:
""" """
Извлекает все кастомные эмодзи из сообщения. Извлекает все кастомные эмодзи из сообщения (текст + подпись).
Args:
message: Сообщение для анализа
Returns: Returns:
Список словарей с информацией об эмодзи Список словарей: {"char": str, "id": str, "offset": int}
""" """
if not message.entities and not message.caption_entities:
return []
# Определяем текст и entities
text = message.text or message.caption text = message.text or message.caption
entities = message.entities or message.caption_entities entities = message.entities or message.caption_entities
@@ -38,37 +43,23 @@ def extract_custom_emojis(message: Message) -> list[dict]:
return [] return []
custom_emojis = [] custom_emojis = []
for entity in entities: for entity in entities:
if entity.type == "custom_emoji": if entity.type == "custom_emoji":
# Извлекаем символ эмодзи emoji_char = _utf16_slice(text, entity.offset, entity.length)
emoji_char = text[entity.offset:entity.offset + entity.length]
custom_emojis.append({ custom_emojis.append({
"char": emoji_char, "char": emoji_char,
"id": entity.custom_emoji_id, "id": entity.custom_emoji_id,
"offset": entity.offset "offset": entity.offset,
}) })
return custom_emojis return custom_emojis
def format_emoji_html(emoji_char: str, emoji_id: str) -> str: def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
"""
Форматирует эмодзи в HTML-тег.
Args:
emoji_char: Символ эмодзи (fallback)
emoji_id: ID кастомного эмодзи
Returns:
HTML-строка
"""
return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>' return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
def escape_html(text: str) -> str: def escape_html(text: str) -> str:
"""Экранирует HTML символы"""
return ( return (
text.replace("&", "&amp;") text.replace("&", "&amp;")
.replace("<", "&lt;") .replace("<", "&lt;")
@@ -76,6 +67,52 @@ def escape_html(text: str) -> str:
) )
def _build_emoji_block(idx: int, emoji_data: dict, is_last: bool) -> str:
"""Формирует текстовый блок для одного эмодзи."""
emoji_char = emoji_data["char"]
emoji_id = emoji_data["id"]
html_code = format_emoji_html(emoji_char, emoji_id)
html_escaped = escape_html(html_code)
block = (
f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
f"📝 <b>HTML-код:</b>\n"
f"<code>{html_escaped}</code>\n\n"
f"🎨 <b>Превью:</b> {html_code}\n"
)
if not is_last:
block += SEPARATOR
return block
def build_pages(custom_emojis: list[dict]) -> list[str]:
"""
Разбивает список эмодзи на страницы, каждая не длиннее MAX_MSG_LEN.
Возвращает список готовых HTML-строк для отправки.
"""
total = len(custom_emojis)
pages: list[str] = []
current_page = ""
for idx, emoji_data in enumerate(custom_emojis, 1):
is_last = (idx == total)
block = _build_emoji_block(idx, emoji_data, is_last)
if current_page and len(current_page) + len(block) > MAX_MSG_LEN:
pages.append(current_page)
current_page = block
else:
current_page += block
if current_page:
pages.append(current_page)
return pages
# ================= КОМАНДА /EMOJI ================= # ================= КОМАНДА /EMOJI =================
@router.message( @router.message(
@@ -83,14 +120,6 @@ def escape_html(text: str) -> str:
IsAdmin() IsAdmin()
) )
async def emoji_extractor_cmd(message: Message) -> None: async def emoji_extractor_cmd(message: Message) -> None:
"""
Извлекает кастомные эмодзи из сообщения.
Доступно только администраторам.
Использование: /emoji (в ответ на сообщение)
"""
# Проверяем, что команда в ответ на сообщение
if not message.reply_to_message: if not message.reply_to_message:
await message.answer( await message.answer(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n" "❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
@@ -98,66 +127,57 @@ async def emoji_extractor_cmd(message: Message) -> None:
"1. Ответьте на сообщение с премиум эмодзи\n" "1. Ответьте на сообщение с премиум эмодзи\n"
"2. Напишите <code>/emoji</code>\n\n" "2. Напишите <code>/emoji</code>\n\n"
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>", "💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
replied_message = message.reply_to_message replied_message = message.reply_to_message
# Извлекаем кастомные эмодзи
custom_emojis = extract_custom_emojis(replied_message) custom_emojis = extract_custom_emojis(replied_message)
if not custom_emojis: if not custom_emojis:
# Нет кастомных эмодзи
await message.answer( await message.answer(
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n" "⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
"В этом сообщении нет премиум эмодзи.\n\n" "В этом сообщении нет премиум эмодзи.\n\n"
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>", "💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
# === ФОРМИРУЕМ ОТВЕТ === total = len(custom_emojis)
pages = build_pages(custom_emojis)
total_pages = len(pages)
output = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}</b>\n\n"
for idx, emoji_data in enumerate(custom_emojis, 1):
emoji_char = emoji_data["char"]
emoji_id = emoji_data["id"]
output += f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
output += f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
# HTML-код (экранированный для отображения)
html_code = format_emoji_html(emoji_char, emoji_id)
html_escaped = escape_html(html_code)
output += f"📝 <b>HTML-код:</b>\n"
output += f"<code>{html_escaped}</code>\n\n"
# Пример использования
output += f"🎨 <b>Превью:</b> {html_code}\n"
if idx < len(custom_emojis):
output += "\n" + "" * 30 + "\n\n"
output += "💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
# Создаём клавиатуру
ikb = InlineKeyboardBuilder() ikb = InlineKeyboardBuilder()
ikb.button(text="✖️ Закрыть", callback_data="emoji_close") ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
# Отправляем
try: try:
for page_num, page_content in enumerate(pages, 1):
# Заголовок только на первой странице
if page_num == 1:
header = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {total}</b>\n\n"
else:
header = f"✨ <b>ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})</b>\n\n"
# Подвал только на последней странице
footer = (
"\n\n💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
if page_num == total_pages
else ""
)
# Кнопка закрытия только на последней странице
markup = ikb.as_markup() if page_num == total_pages else None
await message.answer( await message.answer(
text=output, text=header + page_content + footer,
parse_mode="HTML", parse_mode="HTML",
reply_markup=ikb.as_markup() reply_markup=markup,
) )
logger.info( logger.info(
f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}", f"Извлечено {total} кастомных эмодзи ({total_pages} стр.) "
log_type="EMOJI_EXTRACT" f"админом {message.from_user.id}",
log_type="EMOJI_EXTRACT",
) )
except Exception as e: except Exception as e:
@@ -165,7 +185,7 @@ async def emoji_extractor_cmd(message: Message) -> None:
await message.answer( await message.answer(
"❌ <b>Ошибка извлечения эмодзи</b>\n\n" "❌ <b>Ошибка извлечения эмодзи</b>\n\n"
"Попробуйте позже или обратитесь к разработчику.", "Попробуйте позже или обратитесь к разработчику.",
parse_mode="HTML" parse_mode="HTML",
) )
@@ -173,7 +193,6 @@ async def emoji_extractor_cmd(message: Message) -> None:
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin()) @router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
async def emoji_close_callback(callback) -> None: async def emoji_close_callback(callback) -> None:
"""Закрывает сообщение с эмодзи"""
try: try:
await callback.message.delete() await callback.message.delete()
await callback.answer("✅ Закрыто") await callback.answer("✅ Закрыто")
@@ -189,9 +208,6 @@ async def emoji_close_callback(callback) -> None:
IsAdmin() IsAdmin()
) )
async def emoji_help_cmd(message: Message) -> None: async def emoji_help_cmd(message: Message) -> None:
"""
Справка по работе с кастомными эмодзи.
"""
text = ( text = (
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n" "🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
"📝 <b>Команда /emoji</b>\n" "📝 <b>Команда /emoji</b>\n"
@@ -201,15 +217,11 @@ async def emoji_help_cmd(message: Message) -> None:
"2⃣ Напишите <code>/emoji</code>\n" "2⃣ Напишите <code>/emoji</code>\n"
"3⃣ Скопируйте HTML-код\n\n" "3⃣ Скопируйте HTML-код\n\n"
"💻 <b>Формат HTML-кода:</b>\n" "💻 <b>Формат HTML-кода:</b>\n"
"<code>&lt;tg-emoji emoji-id=\"ID\"&gt;fallback&lt;/tg-emoji&gt;</code>\n\n" '<code>&lt;tg-emoji emoji-id="ID"&gt;fallback&lt;/tg-emoji&gt;</code>\n\n'
"📌 <b>Пример использования в коде:</b>\n"
"<code>text = 'Привет &lt;tg-emoji emoji-id=\"5368324170671202286\"&gt;👍&lt;/tg-emoji&gt;'\n"
"await message.answer(text, parse_mode=\"HTML\")</code>\n\n"
"⚠️ <b>Важно:</b>\n" "⚠️ <b>Важно:</b>\n"
"├─ Используйте <code>parse_mode=\"HTML\"</code>\n" '├─ Используйте <code>parse_mode="HTML"</code>\n'
"├─ Пользователи без Premium видят fallback\n" "├─ Пользователи без Premium видят fallback\n"
"└─ Работает только с кастомными эмодзи\n\n" "└─ Работает только с кастомными эмодзи\n\n"
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>" "💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
) )
await message.answer(text, parse_mode="HTML") await message.answer(text, parse_mode="HTML")

View File

@@ -9,9 +9,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from configs import settings, COMMANDS from configs import settings, COMMANDS
from middleware.loggers import logger from middleware.loggers import logger
__all__ = ("router",) __all__ = ('router',)
router: Router = Router(name="user_id_router") router: Router = Router(name='user_id_router')
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
@@ -19,13 +19,13 @@ router: Router = Router(name="user_id_router")
def get_close_keyboard(): def get_close_keyboard():
"""Создаёт клавиатуру с кнопкой закрытия""" """Создаёт клавиатуру с кнопкой закрытия"""
ikb = InlineKeyboardBuilder() ikb = InlineKeyboardBuilder()
ikb.button(text="✖️ Закрыть", callback_data="id_close") ikb.button(text='✖️ Закрыть', callback_data='id_close')
return ikb.as_markup() return ikb.as_markup()
# ================= КОМАНДА /ID ================= # ================= КОМАНДА /ID =================
@router.message(Command(*COMMANDS.get("id", ["id"]), prefix=settings.PREFIX, ignore_case=True)) @router.message(Command(*COMMANDS.get('id', ['id']), prefix=settings.PREFIX, ignore_case=True))
async def id_cmd(message: Message) -> None: async def id_cmd(message: Message) -> None:
""" """
Показывает информацию о вашем Telegram аккаунте. Показывает информацию о вашем Telegram аккаунте.
@@ -37,12 +37,12 @@ async def id_cmd(message: Message) -> None:
user = message.from_user user = message.from_user
if not user: if not user:
await message.answer("Не удалось получить информацию о пользователе") await message.answer('Не удалось получить информацию о пользователе')
return return
# === ФОРМИРУЕМ ИНФОРМАЦИЮ === # === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
output = "👤 <b>ИНФОРМАЦИЯ О ВАС</b>\n\n" output = '<tg-emoji emoji-id="4961064956368782417">💠</tg-emoji> <b>ИНФОРМАЦИЯ О ВАС</b>\n\n'
# Имя # Имя
full_name_parts = [] full_name_parts = []
@@ -51,28 +51,28 @@ async def id_cmd(message: Message) -> None:
if user.last_name: if user.last_name:
full_name_parts.append(user.last_name) full_name_parts.append(user.last_name)
full_name = " ".join(full_name_parts) if full_name_parts else "Не указано" full_name = ' '.join(full_name_parts) if full_name_parts else 'Не указано'
output += f"📝 <b>Имя:</b> {full_name}\n" output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Имя:</b> {full_name}\n'
# Username # Username
if user.username: if user.username:
output += f"🔗 <b>Username:</b> @{user.username}\n" output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{user.username}\n'
else: else:
output += f"🔗 <b>Username:</b> <i>не установлен</i>\n" output += '<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> <i>не установлен</i>\n'
# ID # ID
output += f"🆔 <b>ID:</b> <code>{user.id}</code>\n\n" output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>ID:</b> <code>{user.id}</code>\n\n'
# Тип аккаунта # Тип аккаунта
if user.is_bot: if user.is_bot:
output += f"🤖 <b>Тип:</b> Бот\n" output += '🤖 <b>Тип:</b> Бот\n'
elif user.is_premium: elif getattr(user, 'is_premium', False):
output += f"⭐️ <b>Тип:</b> Premium пользователь\n" output += '<tg-emoji emoji-id="4961075019477156700">💠</tg-emoji> <b>Тип:</b> Premium пользователь\n'
else: else:
output += f"👥 <b>Тип:</b> Обычный пользователь\n" output += '👥 <b>Тип:</b> Обычный пользователь\n'
# Дополнительная информация # Дополнительная информация
output += "\n📊 <b>Дополнительно:</b>\n" output += '\n<tg-emoji emoji-id="4961141003059725568">💠</tg-emoji> <b>Дополнительно:</b>\n'
# Язык # Язык
if user.language_code: if user.language_code:
@@ -86,36 +86,33 @@ async def id_cmd(message: Message) -> None:
'it': '🇮🇹 Italiano', 'it': '🇮🇹 Italiano',
'pt': '🇵🇹 Português', 'pt': '🇵🇹 Português',
} }
language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}") language = language_names.get(user.language_code, f'🌐 {user.language_code.upper()}')
output += f"├─ Язык: {language}\n" output += f'├─ Язык: {language}\n'
# Информация о чате # Информация о чате
if message.chat.type == "private": if message.chat.type == 'private':
output += f"├─ Чат: 💬 Личные сообщения\n" output += '├─ Чат: 💬 Личные сообщения\n'
else: else:
chat_title = message.chat.title or "Без названия" chat_title = message.chat.title or 'Без названия'
chat_types = { chat_types = {
"group": "👥 Группа", 'group': '👥 Группа',
"supergroup": "👥 Супергруппа", 'supergroup': '👥 Супергруппа',
"channel": "📢 Канал" 'channel': '📢 Канал'
} }
chat_type = chat_types.get(message.chat.type, "💬 Чат") chat_type = chat_types.get(message.chat.type, '💬 Чат')
output += f"├─ Чат: {chat_type}\n" output += f'├─ Чат: {chat_type}\n'
output += f"├─ Название: {chat_title}\n" output += f'├─ Название: {chat_title}\n'
output += f"├─ Chat ID: <code>{message.chat.id}</code>\n" output += f'├─ Chat ID: <code>{message.chat.id}</code>\n'
# Получаем количество участников (только для групп) # Получаем количество участников (только для групп)
try: try:
member_count = await message.bot.get_chat_member_count(message.chat.id) member_count = await message.bot.get_chat_member_count(message.chat.id)
output += f"├─ Участников: {member_count}\n" output += f'├─ Участников: {member_count}\n'
except Exception as e: except Exception as e:
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID") logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
# Message ID # Message ID
output += f"└─ Message ID: <code>{message.message_id}</code>\n\n" output += f'└─ Message ID: <code>{message.message_id}</code>\n\n'
# Подсказка
output += "💡 <i>Эту информацию видите только вы</i>"
# Клавиатура # Клавиатура
keyboard = get_close_keyboard() keyboard = get_close_keyboard()
@@ -124,33 +121,33 @@ async def id_cmd(message: Message) -> None:
try: try:
await message.answer( await message.answer(
text=output, text=output,
parse_mode="HTML", parse_mode='HTML',
reply_markup=keyboard reply_markup=keyboard
) )
logger.debug(f"Команда /id от пользователя {user.id}", log_type="USER_ID") logger.debug(f'Команда /id от пользователя {user.id}', log_type='USER_ID')
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR") logger.error(f'Ошибка отправки информации о пользователе: {e}', log_type='ERROR')
await message.answer("❌ Произошла ошибка при получении информации") await message.answer('❌ Произошла ошибка при получении информации')
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ ================= # ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
@router.callback_query(F.data == "id_close") @router.callback_query(F.data == 'id_close')
async def id_close_callback(callback: CallbackQuery) -> None: async def id_close_callback(callback: CallbackQuery) -> None:
"""Закрывает (удаляет) сообщение с информацией""" """Закрывает (удаляет) сообщение с информацией"""
try: try:
await callback.message.delete() await callback.message.delete()
await callback.answer("✅ Закрыто") await callback.answer('✅ Закрыто')
except Exception as e: except Exception as e:
logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR") logger.error(f'Ошибка удаления сообщения ID: {e}', log_type='ERROR')
await callback.answer("Не удалось удалить сообщение", show_alert=True) await callback.answer('Не удалось удалить сообщение', show_alert=True)
# ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) ================= # ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) =================
@router.message(Command(*COMMANDS.get("myid", ["myid"]), prefix=settings.PREFIX, ignore_case=True)) @router.message(Command(*COMMANDS.get('myid', ['myid']), prefix=settings.PREFIX, ignore_case=True))
async def myid_cmd(message: Message) -> None: async def myid_cmd(message: Message) -> None:
""" """
Быстрый просмотр вашего ID. Быстрый просмотр вашего ID.
@@ -160,21 +157,21 @@ async def myid_cmd(message: Message) -> None:
user = message.from_user user = message.from_user
if not user: if not user:
await message.answer("Не удалось получить ID") await message.answer('Не удалось получить ID')
return return
# Короткий ответ # Короткий ответ
text = f"🆔 Ваш ID: <code>{user.id}</code>" text = f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> Ваш ID: <code>{user.id}</code>'
if user.username: if user.username:
text += f"\n🔗 Username: @{user.username}" text += f'\n<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> Username: @{user.username}'
await message.answer(text, parse_mode="HTML") await message.answer(text, parse_mode='HTML')
# ================= КОМАНДА /CHATID ================= # ================= КОМАНДА /CHATID =================
@router.message(Command(*COMMANDS.get("chatid", ["chatid"]), prefix=settings.PREFIX, ignore_case=True)) @router.message(Command(*COMMANDS.get('chatid', ['chatid']), prefix=settings.PREFIX, ignore_case=True))
async def chatid_cmd(message: Message) -> None: async def chatid_cmd(message: Message) -> None:
""" """
Показывает ID текущего чата. Показывает ID текущего чата.
@@ -183,39 +180,39 @@ async def chatid_cmd(message: Message) -> None:
""" """
chat = message.chat chat = message.chat
output = "💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n" output = '💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n'
# Тип чата # Тип чата
chat_types = { chat_types = {
"private": "💬 Личные сообщения", 'private': '💬 Личные сообщения',
"group": "👥 Группа", 'group': '👥 Группа',
"supergroup": "👥 Супергруппа", 'supergroup': '👥 Супергруппа',
"channel": "📢 Канал" 'channel': '📢 Канал'
} }
chat_type = chat_types.get(chat.type, "💬 Чат") chat_type = chat_types.get(chat.type, '💬 Чат')
output += f"📝 <b>Тип:</b> {chat_type}\n" output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Тип:</b> {chat_type}\n'
if chat.title: if chat.title:
output += f"📌 <b>Название:</b> {chat.title}\n" output += f'📌 <b>Название:</b> {chat.title}\n'
if chat.username: if chat.username:
output += f"🔗 <b>Username:</b> @{chat.username}\n" output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{chat.username}\n'
output += f"🆔 <b>Chat ID:</b> <code>{chat.id}</code>\n" output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>Chat ID:</b> <code>{chat.id}</code>\n'
# Дополнительная информация для групп # Дополнительная информация для групп
if chat.type in ["group", "supergroup"]: if chat.type in ['group', 'supergroup']:
try: try:
member_count = await message.bot.get_chat_member_count(chat.id) member_count = await message.bot.get_chat_member_count(chat.id)
output += f"👥 <b>Участников:</b> {member_count}\n" output += f'👥 <b>Участников:</b> {member_count}\n'
except Exception as e: except Exception as e:
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID") logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
keyboard = get_close_keyboard() keyboard = get_close_keyboard()
await message.answer( await message.answer(
text=output, text=output,
parse_mode="HTML", parse_mode='HTML',
reply_markup=keyboard reply_markup=keyboard
) )

View File

@@ -5,6 +5,7 @@ from aiogram import Router, F
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS from configs import settings, COMMANDS
@@ -123,7 +124,7 @@ async def format_banwords_list(page: int = 0) -> str:
# === КОНФЛИКТНЫЕ ПРАВИЛА === # === КОНФЛИКТНЫЕ ПРАВИЛА ===
if conflict_words or conflict_lemmas: if conflict_words or conflict_lemmas:
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n" output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
output += "<i>(работают только в режиме /stopconflict)</i>\n\n" output += "<i>(работают только в режиме <code>/stopconflict</code> <code>время</code>)</i>\n\n"
if conflict_words: if conflict_words:
output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n" output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n"
@@ -188,9 +189,7 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
""" """
Обработчик команды /listwords. Обработчик команды /listwords.
Отображает список всех правил модерации с разбивкой по категориям. Отображает список всех правил модерации с разбивкой по категориям.
Доступно только администраторам. Доступно только администраторам.
Args: Args:
update: Message или CallbackQuery update: Message или CallbackQuery
""" """
@@ -214,12 +213,18 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
keyboard = get_refresh_kb(page) keyboard = get_refresh_kb(page)
if is_callback: if is_callback:
try:
await message.edit_text( await message.edit_text(
text=text, text=text,
parse_mode="HTML", parse_mode="HTML",
reply_markup=keyboard reply_markup=keyboard
) )
await update.answer("✅ Список обновлён") await update.answer("✅ Список обновлён")
except TelegramBadRequest as e:
if 'message is not modified' in str(e).lower():
await update.answer('✅ Список уже актуален')
return
raise # Другие ошибки пробрасываем
else: else:
await message.answer( await message.answer(
text=text, text=text,
@@ -233,6 +238,6 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже" error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
if is_callback: if is_callback:
await update.answer("❌ Ошибка загрузки", show_alert=True) await update.answer(f"❌ Ошибка загрузки: {e}", show_alert=True)
else: else:
await message.answer(error_text, parse_mode="HTML") await message.answer(error_text, parse_mode="HTML")

View File

@@ -2,11 +2,12 @@
Обработчики команды /report для пользователей Обработчики команды /report для пользователей
""" """
from datetime import datetime from datetime import datetime
from aiogram import Router, F from aiogram import Router, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, User from aiogram.types import Message, CallbackQuery, User
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS from configs import settings, COMMANDS
@@ -18,29 +19,13 @@ __all__ = ("router",)
router: Router = Router(name="report_router") router: Router = Router(name="report_router")
# ================= НАСТРОЙКИ =================
# ID чата для отправки репортов (можно вынести в configs)
# Если None, репорты отправляются всем владельцам в ЛС
REPORT_CHAT_ID = getattr(settings, 'REPORT_CHAT_ID', None)
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================= # ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
def format_user(user: User) -> str: def format_user(user: User) -> str:
""" """Форматирует информацию о пользователе."""
Форматирует информацию о пользователе.
Args:
user: Объект User
Returns:
Отформатированная строка с именем и username
"""
if not user: if not user:
return "Unknown User" return "Unknown User"
# Формируем имя
name_parts = [] name_parts = []
if user.first_name: if user.first_name:
name_parts.append(user.first_name) name_parts.append(user.first_name)
@@ -49,10 +34,8 @@ def format_user(user: User) -> str:
full_name = " ".join(name_parts) if name_parts else "No Name" full_name = " ".join(name_parts) if name_parts else "No Name"
# Добавляем username если есть
if user.username: if user.username:
return f"{full_name} (@{user.username})" return f"{full_name} (@{user.username})"
else:
return full_name return full_name
@@ -72,7 +55,8 @@ def get_report_keyboard(
chat_id: int, chat_id: int,
message_id: int, message_id: int,
reported_user_id: int, reported_user_id: int,
report_id: str report_id: str,
message_thread_id: int | None = None
) -> InlineKeyboardBuilder: ) -> InlineKeyboardBuilder:
""" """
Создает клавиатуру для репорта. Создает клавиатуру для репорта.
@@ -82,17 +66,19 @@ def get_report_keyboard(
message_id: ID сообщения message_id: ID сообщения
reported_user_id: ID пользователя, на которого пожаловались reported_user_id: ID пользователя, на которого пожаловались
report_id: Уникальный ID репорта report_id: Уникальный ID репорта
message_thread_id: ID топика (если есть)
""" """
ikb = InlineKeyboardBuilder() ikb = InlineKeyboardBuilder()
# Кнопки действий thread_id = message_thread_id if message_thread_id is not None else 0
ikb.button( ikb.button(
text="🚫 Забанить", text="🚫 Забанить",
callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}" callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}:{thread_id}"
) )
ikb.button( ikb.button(
text="🗑 Удалить", text="🗑 Удалить",
callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}" callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}:{thread_id}"
) )
ikb.button( ikb.button(
text="✅ Закрыть", text="✅ Закрыть",
@@ -115,17 +101,10 @@ async def report_cmd(message: Message) -> None:
""" """
Отправляет жалобу на сообщение администраторам. Отправляет жалобу на сообщение администраторам.
Доступно всем пользователям.
Использование: Использование:
/report — в ответ на сообщение /report — в ответ на сообщение
/report <причина> — в ответ на сообщение с указанием причины /report <причина> — в ответ на сообщение с указанием причины
Пример:
/report спам
/report оскорбления
""" """
# Проверяем, что команда в ответ на сообщение
if not message.reply_to_message: if not message.reply_to_message:
await message.answer( await message.answer(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n" "❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
@@ -133,7 +112,7 @@ async def report_cmd(message: Message) -> None:
"1. Ответьте на сообщение нарушителя\n" "1. Ответьте на сообщение нарушителя\n"
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n" "2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
"Пример: <code>/report спам</code>", "Пример: <code>/report спам</code>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
@@ -141,103 +120,104 @@ async def report_cmd(message: Message) -> None:
reported_user = reported_message.from_user reported_user = reported_message.from_user
reporter = message.from_user reporter = message.from_user
# Проверка на None
if not reported_user or not reporter: if not reported_user or not reporter:
await message.answer("❌ <b>Ошибка получения данных пользователя</b>", parse_mode="HTML") await message.answer(
"❌ <b>Ошибка получения данных пользователя</b>",
parse_mode="HTML",
)
return return
# Нельзя пожаловаться на самого себя
if reported_user.id == reporter.id: if reported_user.id == reporter.id:
await message.answer( await message.answer(
"⚠️ <b>Нельзя пожаловаться на самого себя</b>", "⚠️ <b>Нельзя пожаловаться на самого себя</b>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
# Нельзя пожаловаться на бота
if reported_user.is_bot: if reported_user.is_bot:
await message.answer( await message.answer(
"⚠️ <b>Нельзя пожаловаться на бота</b>", "⚠️ <b>Нельзя пожаловаться на бота</b>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
# Нельзя пожаловаться на администратора
manager = get_manager() manager = get_manager()
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
if is_admin: if is_admin:
await message.answer( await message.answer(
"⚠️ <b>Нельзя пожаловаться на администратора</b>", "⚠️ <b>Нельзя пожаловаться на администратора</b>",
parse_mode="HTML" parse_mode="HTML",
) )
return return
# Извлекаем причину (опционально) parts = (message.text or "").split(maxsplit=1)
parts = message.text.split(maxsplit=1)
reason = parts[1] if len(parts) > 1 else "Не указана" reason = parts[1] if len(parts) > 1 else "Не указана"
# Генерируем ID репорта
report_id = generate_report_id() report_id = generate_report_id()
# === ФОРМИРУЕМ СООБЩЕНИЕ РЕПОРТА === # thread/topic исходного сообщения (если репортят из топика)
original_message_thread_id = reported_message.message_thread_id
report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n" report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
# Информация о жалобщике
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n" report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
# Информация о нарушителе
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n" report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
# Информация о чате
chat_title = message.chat.title if message.chat.title else "Личные сообщения" chat_title = message.chat.title if message.chat.title else "Личные сообщения"
report_text += f"💬 <b>Чат:</b> {chat_title}\n" report_text += f"💬 <b>Чат:</b> {chat_title}\n"
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n\n" report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
if original_message_thread_id:
report_text += f"📌 <b>Topic ID:</b> <code>{original_message_thread_id}</code>\n"
report_text += "\n"
# Причина
report_text += f"📝 <b>Причина:</b> {reason}\n\n" report_text += f"📝 <b>Причина:</b> {reason}\n\n"
report_text += "📄 <b>Текст сообщения:</b>\n"
# Текст сообщения message_content = None
report_text += f"📄 <b>Текст сообщения:</b>\n"
if reported_message.text: if reported_message.text:
truncated_text = truncate_text(reported_message.text, max_length=300) truncated_text = truncate_text(reported_message.text, max_length=300)
report_text += f"<code>{truncated_text}</code>\n\n" report_text += f"<code>{truncated_text}</code>\n\n"
message_content = reported_message.text
elif reported_message.caption: elif reported_message.caption:
truncated_caption = truncate_text(reported_message.caption, max_length=300) truncated_caption = truncate_text(reported_message.caption, max_length=300)
report_text += f"<code>{truncated_caption}</code>\n\n" report_text += f"<code>{truncated_caption}</code>\n\n"
message_content = reported_message.caption
else: else:
content_type = reported_message.content_type report_text += f"<i>[{reported_message.content_type}]</i>\n\n"
report_text += f"<i>[{content_type}]</i>\n\n"
# Время
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n" report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n" report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
report_text += f"💡 <i>ID репорта: {report_id}</i>" report_text += f"💡 <i>ID репорта: {report_id}</i>"
# Клавиатура
keyboard = get_report_keyboard( keyboard = get_report_keyboard(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=reported_message.message_id, message_id=reported_message.message_id,
reported_user_id=reported_user.id, reported_user_id=reported_user.id,
report_id=report_id report_id=report_id,
message_thread_id=original_message_thread_id
) )
# === ОТПРАВКА РЕПОРТА ===
try: try:
# Если указан админ-чат, отправляем туда report_chat_id = settings.REPORT_CHAT_ID
if REPORT_CHAT_ID: report_thread_id = settings.REPORT_THREAD_ID
await message.bot.send_message(
chat_id=REPORT_CHAT_ID, # Нормализуем: 0 считаем как "без топика"
text=report_text, if report_thread_id == 0:
parse_mode="HTML", report_thread_id = None
reply_markup=keyboard.as_markup()
) if report_chat_id:
send_params = {
"chat_id": report_chat_id,
"text": report_text,
"parse_mode": "HTML",
"reply_markup": keyboard.as_markup(),
}
if report_thread_id is not None:
send_params["message_thread_id"] = report_thread_id # отправка в конкретный топик
await message.bot.send_message(**send_params)
else: else:
# Отправляем всем владельцам
sent_count = 0 sent_count = 0
for owner_id in settings.OWNER_ID: for owner_id in settings.OWNER_ID:
try: try:
@@ -254,24 +234,38 @@ async def report_cmd(message: Message) -> None:
if sent_count == 0: if sent_count == 0:
raise Exception("Не удалось отправить репорт ни одному владельцу") raise Exception("Не удалось отправить репорт ни одному владельцу")
# Подтверждение пользователю await manager.log_report(
report_id=report_id,
reporter_id=reporter.id,
reporter_username=reporter.username,
reported_user_id=reported_user.id,
reported_username=reported_user.username,
chat_id=message.chat.id,
chat_title=chat_title,
message_id=reported_message.message_id,
message_thread_id=original_message_thread_id,
message_text=message_content,
reason=reason
)
await message.answer( await message.answer(
"✅ <b>Жалоба отправлена администраторам</b>\n\n" "✅ <b>Жалоба отправлена администраторам</b>\n\n"
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.", "Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
parse_mode="HTML" parse_mode="HTML",
) )
# Логирование
logger.info( logger.info(
f"Репорт #{report_id}: {reporter.id}{reported_user.id} в чате {message.chat.id}", f"Репорт #{report_id}: {reporter.id}{reported_user.id} в чате {message.chat.id}"
+ (f" (топик {original_message_thread_id})" if original_message_thread_id else ""),
log_type="REPORT" log_type="REPORT"
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT") logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
await message.answer( await message.answer(
"❌ <b>Ошибка отправки жалобы</b>\n\nПопробуйте позже или обратитесь к администратору напрямую.", "❌ <b>Ошибка отправки жалобы</b>\n\n"
parse_mode="HTML" "Попробуйте позже или обратитесь к администратору напрямую.",
parse_mode="HTML",
) )
@@ -280,30 +274,28 @@ async def report_cmd(message: Message) -> None:
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin()) @router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
async def report_ban_callback(callback: CallbackQuery) -> None: async def report_ban_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Забанить'""" """Обрабатывает нажатие кнопки 'Забанить'"""
manager = get_manager()
try: try:
# Парсим данные: report:ban:chat_id:user_id:report_id parts = (callback.data or "").split(":")
parts = callback.data.split(":")
chat_id = int(parts[2]) chat_id = int(parts[2])
user_id = int(parts[3]) user_id = int(parts[3])
report_id = parts[4] report_id = parts[4]
# Баним пользователя
try: try:
await callback.bot.ban_chat_member( await callback.bot.ban_chat_member(chat_id=chat_id, user_id=user_id)
chat_id=chat_id,
user_id=user_id await manager.repo.update_report_status(
report_id=report_id,
status="banned",
processed_by=callback.from_user.id
) )
admin_name = format_user(callback.from_user) admin_name = format_user(callback.from_user)
updated_text = (callback.message.text if callback.message else "") + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})"
# Обновляем сообщение if callback.message:
updated_text = callback.message.text + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})" await callback.message.edit_text(text=updated_text, parse_mode="HTML")
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Пользователь забанен", show_alert=True) await callback.answer("✅ Пользователь забанен", show_alert=True)
@@ -323,30 +315,28 @@ async def report_ban_callback(callback: CallbackQuery) -> None:
@router.callback_query(F.data.startswith("report:delete:"), IsAdmin()) @router.callback_query(F.data.startswith("report:delete:"), IsAdmin())
async def report_delete_callback(callback: CallbackQuery) -> None: async def report_delete_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Удалить'""" """Обрабатывает нажатие кнопки 'Удалить'"""
manager = get_manager()
try: try:
# Парсим данные: report:delete:chat_id:message_id:report_id parts = (callback.data or "").split(":")
parts = callback.data.split(":")
chat_id = int(parts[2]) chat_id = int(parts[2])
message_id = int(parts[3]) message_id = int(parts[3])
report_id = parts[4] report_id = parts[4]
# Удаляем сообщение
try: try:
await callback.bot.delete_message( await callback.bot.delete_message(chat_id=chat_id, message_id=message_id)
chat_id=chat_id,
message_id=message_id await manager.repo.update_report_status(
report_id=report_id,
status="deleted",
processed_by=callback.from_user.id
) )
admin_name = format_user(callback.from_user) admin_name = format_user(callback.from_user)
updated_text = (callback.message.text if callback.message else "") + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})"
# Обновляем сообщение if callback.message:
updated_text = callback.message.text + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})" await callback.message.edit_text(text=updated_text, parse_mode="HTML")
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
)
await callback.answer("✅ Сообщение удалено", show_alert=True) await callback.answer("✅ Сообщение удалено", show_alert=True)
@@ -365,25 +355,28 @@ async def report_delete_callback(callback: CallbackQuery) -> None:
@router.callback_query(F.data.startswith("report:close:"), IsAdmin()) @router.callback_query(F.data.startswith("report:close:"), IsAdmin())
async def report_close_callback(callback: CallbackQuery) -> None: async def report_close_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Закрыть'""" """Обрабатывает нажатие кнопки 'Закрыть' (и удаляет сообщение репорта)"""
manager = get_manager()
try: try:
# Парсим данные: report:close:report_id parts = (callback.data or "").split(":")
parts = callback.data.split(":")
report_id = parts[2] report_id = parts[2]
admin_name = format_user(callback.from_user) await manager.repo.update_report_status(
report_id=report_id,
# Обновляем сообщение status="closed",
updated_text = callback.message.text + f"\n\n✅ <b>Репорт закрыт</b> ({admin_name})" processed_by=callback.from_user.id
# Убираем кнопки
await callback.message.edit_text(
text=updated_text,
parse_mode="HTML"
) )
await callback.answer("✅ Репорт закрыт") await callback.answer("✅ Репорт закрыт")
# Удаляем сообщение с репортом в админ-чате/топике
if callback.message:
try:
await callback.message.delete()
except TelegramBadRequest as e:
logger.warning(f"Не удалось удалить сообщение репорта: {e}", log_type="REPORT")
logger.info( logger.info(
f"Репорт #{report_id} закрыт админом {callback.from_user.id}", f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
log_type="REPORT" log_type="REPORT"
@@ -394,15 +387,11 @@ async def report_close_callback(callback: CallbackQuery) -> None:
await callback.answer("❌ Ошибка выполнения", show_alert=True) await callback.answer("❌ Ошибка выполнения", show_alert=True)
# ================= ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ ================= # ================= ДОПОЛНИТЕЛЬНЫЕ КОМАНДЫ =================
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True)) @router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
async def report_help_cmd(message: Message) -> None: async def report_help_cmd(message: Message) -> None:
""" """Показывает справку по системе репортов."""
Показывает справку по системе репортов.
Доступно всем пользователям.
"""
text = ( text = (
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n" "🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n"
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n" "Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
@@ -425,23 +414,44 @@ async def report_help_cmd(message: Message) -> None:
await message.answer(text, parse_mode="HTML") await message.answer(text, parse_mode="HTML")
@router.message(Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin()) @router.message(
Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True),
IsAdmin()
)
async def report_stats_cmd(message: Message) -> None: async def report_stats_cmd(message: Message) -> None:
""" """Показывает статистику по репортам (для админов)"""
Показывает статистику по репортам (для админов). manager = get_manager()
TODO: Реализовать сохранение статистики в БД stats = await manager.repo.get_report_stats()
""" top_reporters = await manager.repo.get_top_reporters(limit=5)
text = ( top_reported = await manager.repo.get_top_reported_users(limit=5)
"📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
"⚠️ <i>Функция в разработке</i>\n\n" if not stats:
"Планируется:\n" await message.answer("❌ <b>Ошибка получения статистики</b>", parse_mode="HTML")
"Всего репортов за всё время\n" return
"• Топ жалобщиков\n"
"• Топ нарушителей\n" text = "📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
"• Распределение по причинам\n" text += "📈 <b>Общая статистика:</b>\n"
"• Статистика обработки\n\n" text += f"├─ Всего репортов: <b>{stats.get('total', 0)}</b>\n"
"💡 <i>Для реализации нужно добавить таблицу reports в БД</i>" text += f"├─ В ожидании: <b>{stats.get('pending', 0)}</b>\n"
) text += f"├─ Закрыто: <b>{stats.get('closed', 0)}</b>\n"
text += f"├─ Забанено: <b>{stats.get('banned', 0)}</b>\n"
text += f"└─ Удалено: <b>{stats.get('deleted', 0)}</b>\n\n"
if top_reporters:
text += "👥 <b>Топ жалобщиков:</b>\n"
for i, (user_id, username, count) in enumerate(top_reporters, 1):
username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}")
text += f"{i}. {username_display} — <b>{count}</b> реп.\n"
text += "\n"
if top_reported:
text += "⚠️ <b>Топ нарушителей:</b>\n"
for i, (user_id, username, count) in enumerate(top_reported, 1):
username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}")
text += f"{i}. {username_display} — <b>{count}</b> жалоб\n"
text += "\n"
text += f"🕐 <b>Обновлено:</b> {format_datetime(datetime.now())}"
await message.answer(text, parse_mode="HTML") await message.answer(text, parse_mode="HTML")

View File

@@ -10,10 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS from configs import settings, COMMANDS
from middleware.loggers import logger from middleware.loggers import logger
from bot.utils.decorators import log_action from bot.utils import log_action, tg_emoji
__all__ = ("router",) __all__ = ("router",)
CMD: str = "start" CMD: str = "start"
router: Router = Router(name="start_cmd_router") router: Router = Router(name="start_cmd_router")
def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"): def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"):
@@ -21,7 +23,6 @@ def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"
ikb.button(text=text, url=url) ikb.button(text=text, url=url)
return ikb.as_markup() return ikb.as_markup()
@router.callback_query(F.data.casefold() == CMD) @router.callback_query(F.data.casefold() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin()) @router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
@log_action(action_name="START_COMMAND", log_args=True) @log_action(action_name="START_COMMAND", log_args=True)
@@ -36,6 +37,7 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
update: Message или CallbackQuery update: Message или CallbackQuery
""" """
print(123) print(123)
# Определяем тип update и извлекаем данные # Определяем тип update и извлекаем данные
if isinstance(update, CallbackQuery): if isinstance(update, CallbackQuery):
message = update.message message = update.message
@@ -51,98 +53,89 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
# Формируем текст помощи # Формируем текст помощи
help_text = ( help_text = (
"🤖 <b>PrimoGuard - Бот-модератор</b>\n\n" f'{tg_emoji(4961073056677103064)} <b>PrimoGuard - Бот-модератор</b>\n\n'
"Автоматическое удаление сообщений с запрещёнными словами.\n" '<blockquote>Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.</blockquote>\n\n'
"Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
) )
# === Команды просмотра === # === Команды просмотра ===
help_text += ( help_text += (
"📋 <b>Просмотр:</b>\n" f'{tg_emoji(4961141003059725568)} <b>Просмотр:</b>\n'
"/list — список всех правил и слов\n" '<b>/list</b> — список всех правил и слов\n'
"/stats — статистика по удалениям\n" '<b>/stats</b> — статистика по удалениям\n'
"/id — получение айди пользователя\n" '<b>/id</b> — получение айди пользователя\n'
"/chatid — получение айди чата\n\n" '<b>/chatid</b> — получение айди чата\n\n'
) )
# === Постоянные банворды === # === Постоянные банворды ===
help_text += ( help_text += (
" <b>Добавить банворд (постоянно):</b>\n" f'{tg_emoji(4961019408240608234)} <b>Добавить банворд (постоянно):</b>\n'
"/addword <code>слово</code> — подстрока (простой поиск)\n" '<code>/addword</code> <code>слово</code> — подстрока (простой поиск)\n'
"/addlemma <code>слово</code> — лемма (все формы слова)\n" '<code>/addlemma</code> <code>слово</code> — лемма (все формы слова)\n'
"/addpart <code>комбинация</code> — часть (поиск без пробелов)\n\n" '<code>/addpart</code> <code>комбинация</code> — часть (поиск без пробелов)\n\n'
) )
# === Временные банворды === # === Временные банворды ===
help_text += ( help_text += (
" <b>Добавить банворд (временно):</b>\n" f'{tg_emoji(4960719190026618714)} <b>Добавить банворд (временно):</b>\n'
"/addtempword <code>слово минуты</code> — временная подстрока\n" '<code>/addtempword</code> <code>слово минуты</code> — временная подстрока\n'
"/addtemplemma <code>слово минуты</code> — временная лемма\n" '<code>/addtemplemma</code> <code>слово минуты</code> — временная лемма\n'
"<i>Пример: /addtempword спам 60</i>\n\n" '<i>Пример: /addtempword спам 60</i>\n\n'
) )
# === Исключения (whitelist) === # === Исключения (whitelist) ===
help_text += ( help_text += (
" <b>Исключения (whitelist):</b>\n" f'{tg_emoji(4963010134172239128)} <b>Исключения (whitelist):</b>\n'
"/addexcept <code>текст</code> — добавить исключение\n" '<code>/addexcept</code> <code>текст</code> — добавить исключение\n'
"/remexcept <code>текст</code> — удалить исключение\n" '<code>/remexcept</code> <code>текст</code> — удалить исключение\n'
"<i>Исключения не проверяются фильтром</i>\n\n" '<i>Исключения не проверяются фильтром</i>\n\n'
) )
# === Режимы модерации === # === Режимы модерации ===
help_text += ( help_text += (
"🔇 <b>Режим тишины:</b>\n" f'{tg_emoji(4960987543878239236)} <b>Режим тишины:</b>\n'
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n" '<code>/silence</code> <code>минуты</code> — удалять ВСЕ сообщения\n'
"/unsilence — отключить режим тишины\n" '<b>/unsilence</b> — отключить режим тишины\n'
"/report — отправить репорт\n\n" '<code>/report</code> — отправить репорт\n\n'
) )
help_text += ( help_text += (
"⚔️ <b>Режим антиконфликта:</b>\n" f'{tg_emoji(4960986152308835400)} <b>Режим антиконфликта:</b>\n'
"/addconflictword <code>слово</code> — добавить конфликтное слово\n" '<code>/addconflictword</code> <code>слово</code> — добавить конфликтное слово\n'
"/addconflictlemma <code>слово</code> — добавить конфликтную лемму\n" '<code>/addconflictlemma</code> <code>слово</code> — добавить конфликтную лемму\n'
"/stopconflict <code>минуты</code> — активировать режим\n" '<code>/stopconflict</code> <code>минуты</code> — активировать режим\n'
"/unstopconflict — отключить режим\n\n" '<code>/unstopconflict</code> — отключить режим\n\n'
) )
# === Удаление === # === Удаление ===
help_text += ( help_text += (
" <b>Удалить:</b>\n" f'{tg_emoji(4961196485447254983)} <b>Удалить:</b>\n'
"/remword <code>слово</code> — удалить подстроку\n" '<code>/remword</code> <code>слово</code> — удалить подстроку\n'
"/remlemma <code>слово</code> — удалить лемму\n" '<code>/remlemma</code> <code>слово</code> — удалить лемму\n'
"/rempart <code>комбинация</code> — удалить часть\n" '<code>/rempart</code> <code>комбинация</code> — удалить часть\n'
"/remtempword <code>слово</code> — удалить временную подстроку\n" '<code>/remtempword</code> <code>слово</code> — удалить временную подстроку\n'
"/remtemplemma <code>слово</code> — удалить временную лемму\n" '<code>/remtemplemma</code> <code>слово</code> — удалить временную лемму\n'
"/remconflictword <code>слово</code> — удалить конфликтное слово\n" '<code>/remconflictword</code> <code>слово</code> — удалить конфликтное слово\n'
"/remconflictlemma <code>слово</code> — удалить конфликтную лемму\n\n" '<code>/remconflictlemma</code> <code>слово</code> — удалить конфликтную лемму\n\n'
) )
# === Управление админами (только для суперадминов) === # === Управление админами (только для суперадминов) ===
if is_super_admin: if is_super_admin:
help_text += ( help_text += (
"👑 <b>Управление админами (только для владельцев):</b>\n" f'{tg_emoji(4960891456869893259)} <b>Управление админами (только для владельцев):</b>\n'
"/addadmin <code>ID</code> — добавить администратора\n" '<code>/addadmin</code> <i>ID</i> — добавить администратора\n'
"/remadmin <code>ID</code> — удалить администратора\n" '<code>/remadmin</code> <i>ID</i> — удалить администратора\n'
"/listadmins — список всех админов\n\n" '<b>/redactcomment</b> — изменить комментарий под постом\n'
'<b>/listadmins</b> — список всех админов\n\n'
) )
# === Типы проверок === # === Типы проверок ===
help_text += ( help_text += (
" <b>Типы проверок:</b>\n" f'{tg_emoji(4961021096162755737)} <b>Типы проверок:</b>\n'
"• <b>Подстрока</b> — простой поиск в тексте\n" '• <b>Подстрока</b> — простой поиск в тексте\n'
"• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n" '• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n'
"• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n" '• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n'
"• <b>Временные</b> — автоматически удаляются через N минут\n" '• <b>Временные</b> — автоматически удаляются через N минут\n'
"• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n" '• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n'
)
help_text += (
"🔧 <b>Технологии:</b>\n"
"• Unicode-нормализация (латиница→кириллица)\n"
"• Обход через разделители (\"с п а м\"\"спам\")\n"
"• Морфологический анализ (pymorphy3)\n"
"• SQLAlchemy + SQLite с кэшированием\n\n"
"💾 Все настройки сохраняются в базе данных"
) )
# Отправляем ответ # Отправляем ответ
@@ -166,4 +159,4 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
log_type="ERROR" log_type="ERROR"
) )
if is_callback: if is_callback:
await update.answer(" Ошибка отображения справки", show_alert=True) await update.answer(f'{tg_emoji(4963277744994518278)} Ошибка отображения справки', show_alert=True)

View File

@@ -1,11 +1,242 @@
"""
Триггер-хэндлер: реагирует на обращения к Лайле с именем персонажа.
Формат: "Лайла [что угодно] [имя или псевдоним]"
"""
from typing import Dict, List, Optional
import random
from aiogram import Router from aiogram import Router
from aiogram.types import Message from aiogram.types import Message
# Настройки экспорта и роутера __all__ = ("router",)
router: Router = Router(name=__name__) router: Router = Router(name="triggers_router")
CHARACTERS: Dict[str, Dict] = {
"эвелин": {
"aliases": ["эвелин", "эва", "эви"],
"answers": [
"Эвелин умеет молчать так, что хочется говорить.",
"Эва всегда знает больше, чем говорит. Это немного пугает.",
"С ней рядом становится спокойно. Не знаю почему.",
"Интересно, о чём она думает в тишине...",
"Эвелин тихая снаружи. Но внутри — целый ураган, я уверена.",
],
},
"лео": {
"aliases": ["лео", "лёва", "лёня"],
"answers": [
"Лео громкий, яркий и немного безрассудный. Мне нравится!",
"Он смеётся первым и уходит последним. Настоящий.",
"Лео всегда найдёт повод для праздника, даже если его нет.",
"Кажется, он боится тишины. Поэтому и заполняет её собой.",
"За его смехом прячется что-то очень серьёзное...",
],
},
"маркус": {
"aliases": ["маркус", "марк"],
"answers": [
"Маркус говорит мало, но каждое слово весит.",
"Он из тех, кто держит слово даже когда это неудобно.",
"С Маркусом не поспоришь. Не потому что нельзя — просто незачем.",
"Он смотрит так, будто видит тебя насквозь. Немного жутковато.",
"Маркус — тот, на кого можно положиться в самый плохой день.",
],
},
"мари": {
"aliases": ["мари", "маришка", "мариша"],
"answers": [
"Мари — это как утренний свет. Мягко и неожиданно тепло.",
"Она помнит мелочи, которые другие не замечают. Это её суперсила.",
"С Мари любой разговор становится важным.",
"Она улыбается даже когда грустно. Не притворяется — просто верит.",
"Мари умеет прощать. Это редкость.",
],
},
"либе": {
"aliases": ["либе", "либ"],
"answers": [
"Либе... имя звучит как песня на незнакомом языке.",
"Она всегда чуть в стороне, но именно к ней тянутся люди.",
"Либе видит красоту там, где другие видят хаос.",
"Она не объясняет себя. И не должна.",
"С Либе можно молчать — и это не будет неловко.",
],
},
"мотциэль": {
"aliases": ["мотциэль", "мотц", "моц"],
"answers": [
"Мотциэль... даже имя звучит как заклинание.",
"Он существует между мирами. Буквально.",
"Спрашивать его о прошлом — плохая идея. Очень плохая.",
"Мотциэль помнит вещи, которых не было. Или были?",
"Его глаза смотрят в разные эпохи одновременно.",
],
},
"виктор": {
"aliases": ["виктор", "вик", "витя"],
"answers": [
"Виктор всегда побеждает. Это в имени.",
"Он не злой. Просто у него другая шкала ценностей.",
"Виктор говорит правду даже когда это больно. Особенно когда больно.",
"Не стоит играть с ним в слова — проиграешь.",
"За его холодностью — старая-старая усталость.",
],
},
"кситти": {
"aliases": ["кситти", "кси", "ксит"],
"answers": [
"Кситти — маленький хаос в красивой упаковке.",
"Она никогда не делает то, что от неё ожидают. Никогда.",
"С Кситти скучно не бывает. Опасно — бывает. Скучно — нет.",
"Она собирает странные вещи и странных людей.",
"Кситти смеётся над правилами. Потому что сама их придумывает.",
],
},
"кадфаль": {
"aliases": ["кадфаль", "кад", "кадф"],
"answers": [
"Кадфаль несёт что-то древнее в каждом шаге.",
"Он не торопится. У него другое ощущение времени.",
"Кадфаль знает цену словам — поэтому тратит их редко.",
"В его присутствии хочется стоять прямо.",
"Он видел многое. Слишком многое для одной жизни.",
],
},
"вайш": {
"aliases": ["вайш", "вай"],
"answers": [
"Вайш появляется неожиданно и исчезает так же.",
"Её след — это вопросы без ответов.",
"Вайш знает что-то, что тебе лучше не знать.",
"Она не объясняет своих решений. Просто делает.",
"С Вайш никогда не знаешь, друг она или нет.",
],
},
"скаф": {
"aliases": ["скаф"],
"answers": [
"Скаф — имя, которое не забывается.",
"Он работает в тени. Не потому что боится света — просто так удобнее.",
"Скаф знает цену всему. Буквально всему.",
"Его нельзя купить. Его можно только нанять. Это разница.",
"Те, кто встречал Скафа, редко рассказывают об этом дважды.",
],
},
"куарти": {
"aliases": ["куарти", "куар"],
"answers": [
"Куарти — четыре буквы и миллион вопросов.",
"Он улыбается, когда другие нервничают. Это не успокаивает.",
"Куарти коллекционирует долги. Чужие.",
"Говорят, он никогда не проигрывает. Говорят.",
"Куарти появляется именно тогда, когда тебе нужна помощь. И это не случайно.",
],
},
"саэрин": {
"aliases": ["саэрин", "саэ", "сэрин"],
"answers": [
"Саэрин — как туман. Красиво и немного опасно.",
"Она говорит загадками не потому что хочет запутать — просто иначе не умеет.",
"Саэрин помнит всё, что ей говорят. Всё.",
"Её спокойствие пугает больше, чем чужой гнев.",
"Саэрин выбирает слова как оружие — точно и без лишнего.",
],
},
"котики": {
"aliases": ["котики", "котик", "кот", "кошка"],
"answers": [
"Котики — это лучшее, что есть в этом мире. Без обсуждений.",
"Котик сел на тебя — ты избран.",
"Кот смотрит на тебя и думает что-то важное. Наверное.",
"Котики всегда правы. Это научный факт.",
"Маленький тёплый комочек счастья. Что ещё нужно?",
],
},
"нотик": {
"aliases": ["нотик", "нота", "нотка"],
"answers": [
"Нотик! Звучит как маленькая музыкальная нота! 🎵",
"Нотик — тот, кто приносит мелодию туда, где её не хватает.",
"Маленький, но важный. Как все хорошие вещи.",
"Нотик — это и ласково, и загадочно одновременно.",
"Из таких маленьких нотиков складываются большие истории.",
],
},
"илья": {
"aliases": ["илья", "илюха", "илюша"],
"answers": [
"Илья — имя с характером. Твёрдое и живое.",
"Илья всегда знает что делать. Или уверенно делает вид.",
"С Ильёй легко — он не усложняет лишнего.",
"Он из тех, кто сделает, а потом расскажет. Не наоборот.",
"Илья редко жалуется. Чаще просто решает.",
],
},
"ина": {
"aliases": ["ина", "инка", "инуля"],
"answers": [
"Ина — короткое имя, за которым много всего.",
"Она тихая, но запоминается.",
"Ина умеет слушать так, что хочется говорить.",
"В ней есть что-то очень своё, неповторимое.",
"Ина — как маленький огонёк. Незаметный, но греет.",
],
},
"абсцисс": {
"aliases": ["абсцисс", "абс"],
"answers": [
"Абсцисс! Это математика или имя? Хи-хи!",
"Ось абсцисс — горизонталь жизни. Всё движется по ней.",
"Абсцисс звучит как заклинание из учебника.",
"Кто-то мечтает о приключениях, а кто-то — об осях координат!",
"Абсцисс и ордината. Звучит как имена двух загадочных персонажей!",
],
},
}
# Имена, на которые Лайла откликается
LAYLA_NAMES = ["лайла", "лайл", "лая"]
def find_character_answer(text: str) -> Optional[str]:
"""
Проверяет:
1. Есть ли в тексте обращение к Лайле
2. Есть ли имя персонажа
Возвращает случайный ответ или None.
"""
text_lower = text.lower()
# Проверяем обращение к Лайле
if not any(name in text_lower for name in LAYLA_NAMES):
return None
# Ищем персонажа
for character, data in CHARACTERS.items():
for alias in data["aliases"]:
if alias in text_lower:
return random.choice(data["answers"])
return None
@router.message() @router.message()
async def default_msg(message: Message) -> None: async def handle_triggers(message: Message) -> None:
"""Обработчик всех необработанных сообщений.""" """
Реагирует только если:
- Сообщение от живого человека
- В тексте есть обращение к Лайле
- В тексте есть имя персонажа
На всё остальное — молчит.
"""
#if not message.text or not message.from_user or message.from_user.is_bot:
#return
#response = find_character_answer(message.text)
#if response:
#await message.reply(response)
return return

View File

@@ -1 +0,0 @@
from .decision import *

View File

@@ -1,18 +0,0 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
"""
Получение клавиатуры Принятия\Отклонить.
:param thread_id: Айди действия.
:param kind: Вид для клавиатуры.
:return: Инлайн-клавиатуру (Принять, Отклонить).
"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
)
return ikb.as_markup()

View File

@@ -1,25 +1,24 @@
""" """
Middleware для проверки сообщений на запрещённые слова (банворды). Middleware для проверки сообщений на запрещённые слова (банворды).
Pipeline проверки: ✅ ИСПРАВЛЕНО:
1. Пропускаем админов и служебные сообщения - Полная нормализация текста с использованием UNICODE_MAP
2. Проверяем whitelist (исключения) - Удаление повторов символов (леееейн → лейн)
3. Проверяем режим silence (удаляем всё) - Игнорирование разделителей (л.е.й.н → лейн)
4. Проверяем режим conflict (конфликтные слова) - Поддержка всех типов проверок (SUBSTRING, LEMMA, PART, CONFLICT)
5. Проверяем постоянные банворды (substring, lemma, part) - Белый список и режимы тишины/конфликта
6. Проверяем временные банворды - Нет уведомлений в режиме тишины
7. Если найдено - удаляем, логируем, уведомляем админов
НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
""" """
from typing import Callable, Dict, Any, Awaitable, Optional
from typing import Callable, Dict, Any, Awaitable, Optional, Set
import re import re
import unicodedata
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from configs import settings from configs import settings, UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
from database import get_manager, BanWordType from database import get_manager, BanWordType
from bot.special import process_text, extract_words, get_lemma from bot.special import process_text, extract_words, get_lemma
from middleware.loggers import logger from middleware.loggers import logger
@@ -27,18 +26,102 @@ from middleware.loggers import logger
__all__ = ("BanWordsMiddleware",) __all__ = ("BanWordsMiddleware",)
class TextNormalizer:
"""
Класс для многоступенчатой нормализации текста.
Приводит различные юникод-символы к базовым буквам,
удаляет повторы, убирает разделители.
"""
# Объединяем все словари замен в один
FULL_MAP = {}
FULL_MAP.update(LATIN_TO_CYRILLIC)
FULL_MAP.update(CYRILLIC_NORMALIZE)
FULL_MAP.update(UNICODE_MAP)
# Символы-разделители, которые могут быть вставлены между буквами
SEPARATORS = re.compile(r'[\s\.\-_,;:|]+', re.UNICODE)
# Паттерн для поиска повторяющихся букв (3+ раза)
REPEAT_PATTERN = re.compile(r'([а-яёa-z])\1{2,}', re.IGNORECASE)
@classmethod
def normalize_characters(cls, text: str) -> str:
"""
Заменяет все символы из FULL_MAP на их базовые эквиваленты.
Проходит по строке посимвольно для максимальной замены.
"""
result = []
for ch in text:
# Сначала пробуем заменить по карте
if ch in cls.FULL_MAP:
result.append(cls.FULL_MAP[ch])
else:
result.append(ch)
# Приводим к нижнему регистру после замен (чтобы избежать потери регистра в карте)
return ''.join(result).lower()
@classmethod
def remove_separators(cls, text: str) -> str:
"""Удаляет разделители между буквами (пробелы, точки и т.д.)"""
return cls.SEPARATORS.sub('', text)
@classmethod
def collapse_repeats(cls, text: str, max_repeat: int = 2) -> str:
"""
Заменяет повторения символов более max_repeat подряд на один/два символа.
По умолчанию оставляет максимум 2 (леееейн → леейн? но обычно хватит 2).
Можно настроить: для банворда "лейн" превратит "леееейн" в "леейн", что всё равно содержит "лейн".
"""
def repl(m):
ch = m.group(1)
# Оставляем два символа, чтобы не терять удвоенные буквы (например, "дд" в слове "поддон")
return ch * 2
return cls.REPEAT_PATTERN.sub(repl, text)
@classmethod
def normalize_full(cls, text: str, remove_sep: bool = True, collapse: bool = True) -> str:
"""
Полная нормализация:
1. Unicode нормализация (NFKC) для разложения составных символов
2. Замена по карте
3. Приведение к нижнему регистру
4. Удаление разделителей (опционально)
5. Схлопывание повторов (опционально)
"""
# NFKC разлагает символы типа "ё" в "е" + умляут, но нам лучше оставить как есть,
# т.к. у нас есть прямые замены. Однако для совместимости применим.
text = unicodedata.normalize('NFKC', text)
# Замена символов
text = cls.normalize_characters(text)
# Удаление разделителей
if remove_sep:
text = cls.remove_separators(text)
# Схлопывание повторов
if collapse:
text = cls.collapse_repeats(text)
return text
@classmethod
def normalize_for_part(cls, text: str) -> str:
"""
Нормализация для типа PART:
- Полная нормализация
- Удаление всех не-буквенных символов (кроме пробелов)
- Приведение к нижнему регистру
"""
text = cls.normalize_full(text, remove_sep=False, collapse=True)
# Оставляем только буквы и пробелы
text = re.sub(r'[^а-яёa-z\s]', '', text, flags=re.IGNORECASE)
text = re.sub(r'\s+', ' ', text).strip()
return text.lower()
class BanWordsMiddleware(BaseMiddleware): class BanWordsMiddleware(BaseMiddleware):
"""
Middleware для фильтрации сообщений с банвордами.
Проверяет каждое текстовое сообщение на наличие запрещённых слов,
удаляет спам и уведомляет администраторов.
"""
def __init__(self): def __init__(self):
"""Инициализирует middleware"""
super().__init__() super().__init__()
self.manager = get_manager() self.manager = get_manager()
self.normalizer = TextNormalizer()
async def __call__( async def __call__(
self, self,
@@ -46,245 +129,143 @@ class BanWordsMiddleware(BaseMiddleware):
event: Message, event: Message,
data: Dict[str, Any] data: Dict[str, Any]
) -> Any: ) -> Any:
""" # Проверяем наличие текста или подписи
Обрабатывает входящие сообщения.
Args:
handler: Следующий обработчик в цепочке
event: Сообщение от пользователя
data: Данные из диспетчера
Returns:
Any: Результат обработчика или None (если сообщение удалено)
"""
# Пропускаем не-текстовые сообщения
if not event.text and not event.caption: if not event.text and not event.caption:
return await handler(event, data) return await handler(event, data)
# Получаем текст (из text или caption)
message_text = event.text or event.caption message_text = event.text or event.caption
# Пропускаем команды (начинаются с /) # Игнорируем команды
if message_text.startswith('/'): if message_text.startswith('/'):
return await handler(event, data) return await handler(event, data)
# Проверяем, является ли пользователь админом # Проверка на админа
user_id = event.from_user.id user_id = event.from_user.id
is_super_admin = user_id in settings.OWNER_ID is_super_admin = user_id in settings.OWNER_ID
is_admin = is_super_admin or self.manager.is_admin_cached(user_id) is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
# Админы пропускаются
if is_admin: if is_admin:
return await handler(event, data) return await handler(event, data)
# Проверяем сообщение на банворды # Проверяем сообщение на спам
spam_result = await self._check_message(message_text) spam_result = await self._check_message(message_text)
if spam_result: if spam_result:
# Найден спам - удаляем и уведомляем
await self._handle_spam(event, spam_result) await self._handle_spam(event, spam_result)
return None # Не продолжаем обработку return None # Сообщение удалено, дальше не обрабатываем
# Сообщение чистое - пропускаем дальше
return await handler(event, data) return await handler(event, data)
@staticmethod
def _normalize_for_part_check(text: str) -> str:
"""
Нормализует текст для проверки частей слов.
Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру.
Args:
text: Исходный текст
Returns:
str: Нормализованный текст (только буквы и цифры, нижний регистр)
Examples:
"@Astrixkeepbot" -> "astrixkeepbot"
"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]]: async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
""" """
Проверяет сообщение на наличие банвордов. Многоступенчатая проверка текста.
Возвращает словарь с причиной блокировки или None.
Args:
text: Текст сообщения
Returns:
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
""" """
# Нормализуем текст для проверки # 1. Повторяющиеся символы (например, "леееейн") — блокируем сразу
text_lower = text.lower() repeat_result = self._check_repeated_chars(text)
text_processed = process_text(text_lower) if repeat_result:
return repeat_result
# Дополнительно нормализуем повторяющиеся буквы для всех проверок # 2. Получаем кэшированные списки
text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1) substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
part_words = self.manager.get_banwords_cached(BanWordType.PART)
conflict_substring = self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING)
conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
logger.debug( # 3. Белый список
f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', " if self.manager.is_whitelisted(text):
f"нормализованный='{text_normalized[:50]}'", logger.debug(f"⏭️ Пропуск по белому списку: {text[:30]}", log_type="BANWORDS")
log_type="BANWORDS"
)
# === 1. WHITELIST (исключения) ===
# Проверяем оба варианта: с повторами и без
if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized):
logger.debug(
f"Сообщение содержит whitelist слово",
log_type="BANWORDS"
)
return None return None
# === 2. SILENCE MODE (удаляем всё) === # 4. Режим тишины
if await self.manager.is_silence_active(): if await self.manager.is_silence_active():
return { return {"word": "[режим тишины]", "type": "silence"}
"word": "[режим тишины]",
"type": "silence"
}
# === 3. CONFLICT MODE (конфликтные слова) === # 5. Режим конфликта (более мягкие правила)
if await self.manager.is_conflict_active(): if await self.manager.is_conflict_active():
# Проверяем конфликтные подстроки (с нормализацией) # Проверка conflict_substring (с нормализацией)
conflict_substring = self.manager.get_banwords_cached( normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
BanWordType.CONFLICT_SUBSTRING
)
for word in conflict_substring: for word in conflict_substring:
word_normalized = self._normalize_repeated_chars(word, max_repeats=1) norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
if word_normalized in text_normalized: if norm_word in normalized_text:
return {"word": word, "type": "conflict_substring"} return {"word": word, "type": "conflict_substring"}
# Проверяем конфликтные леммы # conflict_lemma
conflict_lemma = self.manager.get_banwords_cached( for word_text in extract_words(text):
BanWordType.CONFLICT_LEMMA lemma = get_lemma(word_text)
)
words_in_text = extract_words(text_processed)
for word_text in words_in_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 (подстроки) с нормализацией === # Если в конфликтном режиме ничего не найдено — пропускаем
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
for word in substring_words:
# Нормализуем и банворд, и текст
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_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_part_normalized:
logger.info(
f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') "
f"в '{text_part_normalized[:100]}'",
log_type="BANWORDS"
)
return {"word": part, "type": "part"}
# === 6. LEMMA (нормальные формы слов) ===
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
if lemma_words:
words_in_text = extract_words(text_processed)
for word_text in words_in_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"}
# Банворды не найдены
return None return None
async def _handle_spam( # 6. Обычный режим: проверка substring (с удалением разделителей и схлопыванием повторов)
self, normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
message: Message, for word in substring_words:
spam_result: Dict[str, str] norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
) -> None: if norm_word in normalized_text:
""" logger.info(f"✅ SUBSTRING: '{word}'", log_type="BANWORDS")
Обрабатывает найденный спам: удаляет, логирует, уведомляет. return {"word": word, "type": "substring"}
Args: # 7. Проверка part (строгая нормализация, только буквы и пробелы)
message: Сообщение со спамом part_normalized = self.normalizer.normalize_for_part(text)
spam_result: Результат проверки (слово + тип) for part in part_words:
norm_part = self.normalizer.normalize_for_part(part)
if norm_part in part_normalized:
logger.info(f"✅ PART: '{part}'", log_type="BANWORDS")
return {"word": part, "type": "part"}
# 8. Проверка lemma
for word_text in extract_words(text):
# Для леммы тоже применяем нормализацию (удаляем разделители, схлопываем повторы)
normalized_word = self.normalizer.normalize_full(word_text, remove_sep=True, collapse=True)
lemma = get_lemma(normalized_word)
if lemma in lemma_words:
logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS")
return {"word": lemma, "type": "lemma"}
return None
def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]:
""" """
Проверяет на наличие 3+ повторяющихся букв подряд.
Использует сырой текст без нормализации (чтобы поймать "леееейн").
"""
# Ищем повторения букв (только кириллица/латиница)
pattern = re.compile(r'([а-яёa-zA-Z])\1{2,}', re.IGNORECASE)
matches = pattern.finditer(text)
for match in matches:
char = match.group(1)
count = len(match.group(0))
if count >= 3:
logger.info(f"🔥 ПОВТОРЫ: '{match.group(0)}' ({count}x)", log_type="BANWORDS")
return {"word": f"'{match.group(0)}' ({count}x)", "type": "repeated_chars"}
return None
async def _handle_spam(self, message: Message, spam_result: Dict[str, str]) -> None:
"""Обрабатывает спам-сообщение: удаляет, логирует, уведомляет (кроме silence)"""
user = message.from_user user = message.from_user
matched_word = spam_result["word"] matched_word = spam_result["word"]
match_type = spam_result["type"] match_type = spam_result["type"]
# Получаем текст сообщения
message_text = message.text or message.caption or "[нет текста]" message_text = message.text or message.caption or "[нет текста]"
# === 1. УДАЛЯЕМ СООБЩЕНИЕ === # В режиме тишины удаляем молча
if match_type == "silence":
try: try:
await message.delete() await message.delete()
logger.info( logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча", log_type="BANWORDS")
f"Удалено сообщение от @{user.username or user.id} "
f"(слово: '{matched_word}', тип: {match_type})",
log_type="BANWORDS",
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"Не удалено (silence): {e}", log_type="BANWORDS")
f"Не удалось удалить сообщение: {e}",
log_type="BANWORDS",
user=f"@{user.username}" if user.username else f"id{user.id}"
)
return return
# === 2. ЛОГИРУЕМ В БД === # Удаляем сообщение
try:
await message.delete()
logger.info(f"🚫 @{user.username or user.id}: '{matched_word}' ({match_type})", log_type="BANWORDS")
except TelegramBadRequest as e:
logger.error(f"Не удалено: {e}", log_type="BANWORDS")
return
# Логируем в БД
await self.manager.log_spam( await self.manager.log_spam(
user_id=user.id, user_id=user.id,
username=user.username or f"id{user.id}", username=user.username or f"id{user.id}",
@@ -294,7 +275,7 @@ class BanWordsMiddleware(BaseMiddleware):
match_type=match_type match_type=match_type
) )
# === 3. УВЕДОМЛЯЕМ АДМИНОВ === # Уведомляем админов
await self._notify_admins(message, matched_word, match_type, message_text) await self._notify_admins(message, matched_word, match_type, message_text)
async def _notify_admins( async def _notify_admins(
@@ -303,87 +284,64 @@ class BanWordsMiddleware(BaseMiddleware):
matched_word: str, matched_word: str,
match_type: str, match_type: str,
message_text: str message_text: str
) -> None: ) -> None:
""" """Отправляет уведомление об удалении в админ-чат (берёт ID из БД)"""
Отправляет уведомление в админский чат с кнопками.
Args:
message: Удалённое сообщение
matched_word: Слово, по которому сработал фильтр
match_type: Тип проверки
message_text: Текст сообщения
"""
user = message.from_user user = message.from_user
username = f"@{user.username}" if user.username else f"ID: {user.id}" username = f"@{user.username}" if user.username else f"ID: {user.id}"
# Получаем количество предыдущих нарушений
spam_count = await self.manager.get_user_spam_count(user.id) spam_count = await self.manager.get_user_spam_count(user.id)
chat_title = message.chat.title or "Без названия"
source_thread_id = message.message_thread_id
# Формируем текст уведомления
notification_text = ( notification_text = (
f"🚫 <b>Удалено сообщение</b>\n\n" f"🚫 <b>Удалено сообщение</b>\n\n"
f"👤 <b>Пользователь:</b> {username}\n" f"👤 <b>Пользователь:</b> {username}\n"
f"🆔 <b>ID:</b> <code>{user.id}</code>\n" f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
f"📊 <b>Нарушений:</b> {spam_count}\n\n" f"📊 <b>Нарушений:</b> {spam_count}\n\n"
f"🔍 <b>Триггер:</b> <code>{matched_word}</code>\n" f"💬 <b>Чат:</b> {self._escape_html(chat_title)}\n"
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {match_type}\n\n" f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
f"💬 <b>Текст:</b>\n" f"{'📌 <b>Topic ID:</b> <code>' + str(source_thread_id) + '</code>\n' if source_thread_id else ''}"
f"<code>{self._escape_html(message_text[:500])}</code>" f"🔗 <b>Message ID:</b> <code>{message.message_id}</code>\n\n"
f"🔍 <b>Триггер:</b> <code>{self._escape_html(matched_word)}</code>\n"
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\n\n"
f"💬 <b>Текст:</b>\n<code>{self._escape_html(message_text[:500])}</code>"
) )
# Создаём клавиатуру с действиями
keyboard = InlineKeyboardMarkup(inline_keyboard=[ keyboard = InlineKeyboardMarkup(inline_keyboard=[
[ [
InlineKeyboardButton( InlineKeyboardButton(text="🔨 Забанить", callback_data=f"spam_ban:{user.id}:{message.chat.id}"),
text="🔨 Забанить", InlineKeyboardButton(text=" Закрыть", callback_data="spam_close")
callback_data=f"spam_ban:{user.id}:{message.chat.id}"
),
InlineKeyboardButton(
text="✅ Закрыть",
callback_data="spam_close"
)
], ],
[ [InlineKeyboardButton(text="📊 Статистика", callback_data=f"spam_stats:{user.id}")]
InlineKeyboardButton(
text="📊 Статистика",
callback_data=f"spam_stats:{user.id}"
)
]
]) ])
# Отправляем уведомление
try: try:
bot = message.bot # ✅ Получаем настройки из БД (динамические, установленные через /settings)
await bot.send_message( admin_chat_id = await self.manager.get_bot_setting("admin_chat_id")
chat_id=settings.ADMIN_CHAT_ID, admin_thread_id = await self.manager.get_bot_setting("admin_thread_id")
if admin_chat_id:
await message.bot.send_message(
chat_id=int(admin_chat_id),
text=notification_text, text=notification_text,
reply_markup=keyboard, reply_markup=keyboard,
parse_mode="HTML" parse_mode="HTML",
message_thread_id=int(admin_thread_id) if admin_thread_id else None
) )
except Exception as e: except Exception as e:
logger.error( logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS")
f"Ошибка отправки уведомления админам: {e}",
log_type="BANWORDS"
)
@staticmethod @staticmethod
def _get_type_emoji(match_type: str) -> str: def _get_type_emoji(match_type: str) -> str:
"""Возвращает эмодзи для типа проверки""" return {
emoji_map = {
"substring": "🔤", "substring": "🔤",
"lemma": "📖", "lemma": "📖",
"part": "🧩", "part": "🧩",
"silence": "🔇", "silence": "🔇",
"conflict_substring": "⚔️", "conflict_substring": "⚔️",
"conflict_lemma": "⚔️" "conflict_lemma": "⚔️",
} "repeated_chars": "🔁"
return emoji_map.get(match_type, "") }.get(match_type, "")
@staticmethod @staticmethod
def _escape_html(text: str) -> str: def _escape_html(text: str) -> str:
"""Экранирует HTML символы для безопасного отображения""" return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)

View File

@@ -68,7 +68,6 @@ class UserSpamStats:
"""Удаляет старые запросы за пределами временного окна""" """Удаляет старые запросы за пределами временного окна"""
cutoff_time = current_time - time_window cutoff_time = current_time - time_window
# Удаляем старые запросы
new_times = [] new_times = []
new_contexts = [] new_contexts = []
@@ -121,7 +120,6 @@ class UserSpamStats:
current_time = time() current_time = time()
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот) # 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
# Если 5+ сообщений за 2 секунды => мгновенный мут
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0] very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
if len(very_recent) >= 5: if len(very_recent) >= 5:
return { return {
@@ -130,7 +128,7 @@ class UserSpamStats:
'severity': 1.0, 'severity': 1.0,
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды", 'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
'instant_block': True, 'instant_block': True,
'block_duration': 600.0 # 10 минут сразу 'block_duration': 600.0
} }
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд # 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
@@ -142,13 +140,12 @@ class UserSpamStats:
'severity': 0.95, 'severity': 0.95,
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд", 'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
'instant_block': True, 'instant_block': True,
'block_duration': 300.0 # 5 минут 'block_duration': 300.0
} }
# 3. Медиа-флуд (стикеры/фото/видео) # 3. Медиа-флуд
media_contexts = [ctx for ctx in recent_contexts if ctx.media_type] media_contexts = [ctx for ctx in recent_contexts if ctx.media_type]
if len(media_contexts) >= 7: if len(media_contexts) >= 7:
# Проверяем скорость отправки медиа
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0] media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
if len(media_recent) >= 6: if len(media_recent) >= 6:
return { return {
@@ -157,7 +154,7 @@ class UserSpamStats:
'severity': 0.9, 'severity': 0.9,
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд", 'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
'instant_block': True, 'instant_block': True,
'block_duration': 240.0 # 4 минуты 'block_duration': 240.0
} }
return { return {
@@ -173,14 +170,14 @@ class UserSpamStats:
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:
return { return {
'is_spam': True, 'is_spam': True,
'reason': 'identical_messages', 'reason': 'identical_messages',
'severity': 0.85, 'severity': 0.85,
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)", 'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
'instant_block': True, 'instant_block': True,
'block_duration': 180.0 # 3 минуты 'block_duration': 180.0
} }
# 5. Проверка спама callback кнопок # 5. Проверка спама callback кнопок
@@ -189,14 +186,14 @@ class UserSpamStats:
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 >= 10: # 10 нажатий одной кнопки if count >= 10:
return { return {
'is_spam': True, 'is_spam': True,
'reason': 'callback_spam', 'reason': 'callback_spam',
'severity': 0.8, 'severity': 0.8,
'details': f"🔘 Спам кнопки: {count} нажатий", 'details': f"🔘 Спам кнопки: {count} нажатий",
'instant_block': True, 'instant_block': True,
'block_duration': 120.0 # 2 минуты 'block_duration': 120.0
} }
return {'is_spam': False, 'reason': None, 'severity': 0.0} return {'is_spam': False, 'reason': None, 'severity': 0.0}
@@ -269,11 +266,11 @@ class AntiSpamMiddleware(BaseMiddleware):
- Детекция скорости отправки сообщений - Детекция скорости отправки сообщений
- Адаптивная длительность блокировки - Адаптивная длительность блокировки
- Различает типы активности - Различает типы активности
- Бот никогда не банит сам себя
""" """
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 = 12, rate_limit_callback: int = 12,
@@ -281,12 +278,10 @@ class AntiSpamMiddleware(BaseMiddleware):
time_window: float = 10.0, time_window: float = 10.0,
# Предупреждения (уже не так важны — флуд блокируется мгновенно)
warning_limit: int = 3, warning_limit: int = 3,
base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги base_block_duration: float = 120.0,
max_block_duration: float = 3600.0, max_block_duration: float = 3600.0,
# Опции
whitelist_admins: bool = True, whitelist_admins: bool = True,
progressive_blocking: bool = True, progressive_blocking: bool = True,
enable_smart_detection: bool = True, enable_smart_detection: bool = True,
@@ -318,7 +313,6 @@ class AntiSpamMiddleware(BaseMiddleware):
context.is_reply = event.reply_to_message is not None context.is_reply = event.reply_to_message is not None
context.is_command = bool(context.text and context.text.startswith('/')) context.is_command = bool(context.text and context.text.startswith('/'))
# Определяем тип медиа
if event.photo: if event.photo:
context.media_type = 'photo' context.media_type = 'photo'
elif event.video: elif event.video:
@@ -350,7 +344,6 @@ class AntiSpamMiddleware(BaseMiddleware):
else: else:
base_limit = self.rate_limit_text base_limit = self.rate_limit_text
# Применяем репутацию
if self.enable_reputation: if self.enable_reputation:
base_limit = int(base_limit * user_stats.reputation) base_limit = int(base_limit * user_stats.reputation)
@@ -392,6 +385,11 @@ class AntiSpamMiddleware(BaseMiddleware):
if user_id is None: if user_id is None:
return await handler(event, data) return await handler(event, data)
# ✅ ИСПРАВЛЕНИЕ: пропускаем самого бота (предотвращает самобан)
bot = data.get("bot")
if bot and user_id == bot.id:
return await handler(event, data)
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}" user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
# Whitelist для администраторов # Whitelist для администраторов
@@ -414,7 +412,7 @@ class AntiSpamMiddleware(BaseMiddleware):
user=user_str user=user_str
) )
# НЕ отправляем сообщение каждый раз — только callback answer # Только для callback — отвечаем алертом, для сообщений молчим
if isinstance(event, CallbackQuery): if isinstance(event, CallbackQuery):
await event.answer( await event.answer(
f"🚫 Блокировка: {self._format_duration(remaining)}", f"🚫 Блокировка: {self._format_duration(remaining)}",
@@ -426,10 +424,10 @@ class AntiSpamMiddleware(BaseMiddleware):
# Извлекаем контекст сообщения # Извлекаем контекст сообщения
context = self._extract_context(event) context = self._extract_context(event)
# Добавляем запрос СНАЧАЛА (важно для детекции скорости) # Добавляем запрос СНАЧАЛА важно для детекции скорости флуда
user_stats.add_request(current_time, context) 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)
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ========== # ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
@@ -437,10 +435,9 @@ class AntiSpamMiddleware(BaseMiddleware):
spam_analysis = user_stats.detect_spam_patterns(self.time_window) spam_analysis = user_stats.detect_spam_patterns(self.time_window)
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'): if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
# МГНОВЕННАЯ БЛОКИРОВКА
block_duration = spam_analysis.get('block_duration', 300.0) block_duration = spam_analysis.get('block_duration', 300.0)
user_stats.block(current_time, block_duration) user_stats.block(current_time, block_duration)
user_stats.warnings = self.warning_limit # Максимум варнингов user_stats.warnings = self.warning_limit
spam_stats.instant_blocks += 1 spam_stats.instant_blocks += 1
logger.error( logger.error(
@@ -461,7 +458,7 @@ class AntiSpamMiddleware(BaseMiddleware):
if isinstance(event, Message): if isinstance(event, Message):
try: try:
await event.answer(block_message, parse_mode="HTML") await event.answer(block_message, parse_mode="HTML")
except: except Exception:
pass pass
elif isinstance(event, CallbackQuery): elif isinstance(event, CallbackQuery):
await event.answer( await event.answer(
@@ -471,10 +468,9 @@ class AntiSpamMiddleware(BaseMiddleware):
return None return None
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ========== # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ ==========
effective_limit = self._get_effective_rate_limit(user_stats, context) effective_limit = self._get_effective_rate_limit(user_stats, context)
# Подсчитываем релевантные запросы
relevant_requests = 0 relevant_requests = 0
for req_context in user_stats.message_contexts: for req_context in user_stats.message_contexts:
if context.is_forward and req_context.is_forward: if context.is_forward and req_context.is_forward:
@@ -493,7 +489,6 @@ class AntiSpamMiddleware(BaseMiddleware):
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
@@ -505,7 +500,6 @@ class AntiSpamMiddleware(BaseMiddleware):
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)
@@ -526,7 +520,7 @@ class AntiSpamMiddleware(BaseMiddleware):
if isinstance(event, Message): if isinstance(event, Message):
try: try:
await event.answer(block_message, parse_mode="HTML") await event.answer(block_message, parse_mode="HTML")
except: except Exception:
pass pass
elif isinstance(event, CallbackQuery): elif isinstance(event, CallbackQuery):
await event.answer( await event.answer(
@@ -536,7 +530,6 @@ class AntiSpamMiddleware(BaseMiddleware):
return None return None
# Предупреждение (только для сообщений, не для callback)
if isinstance(event, Message): if isinstance(event, Message):
warning_message = ( warning_message = (
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n" f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
@@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware):
) )
try: try:
await event.answer(warning_message, parse_mode="HTML") await event.answer(warning_message, parse_mode="HTML")
except: except Exception:
pass pass
return None return None

View File

@@ -36,3 +36,4 @@ from .auto_delete import *
# ================= DECORATORS ================= # ================= DECORATORS =================
from .decorators import * from .decorators import *
from .telegram_emoji import *

View File

@@ -0,0 +1,4 @@
def tg_emoji(id: str, emoji: str = "💠") -> str:
"""Генерирует HTML-тег кастомного эмодзи."""
return f'<tg-emoji emoji-id="{id}">{emoji}</tg-emoji>'

View File

@@ -11,6 +11,12 @@ COMMANDS: Final[dict[str, list[str]]] = {
"st", "on", "вкл", # сокращения "st", "on", "вкл", # сокращения
], ],
"stop": [
"stop", "стоп", "завершить", # основные
"off", "ыещз", "cnjg", "pfdthibnm", # раскладка + сокращение
"щаа", # сокращения
],
"help": [ "help": [
"help", "помощь", "допомога", # основные "help", "помощь", "допомога", # основные
"рудз", "gjvjom", "ljgjvjuf", # раскладка "рудз", "gjvjom", "ljgjvjuf", # раскладка
@@ -30,335 +36,329 @@ COMMANDS: Final[dict[str, list[str]]] = {
], ],
# ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ==================== # ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ====================
"addword": [ "addword": [
"addword", "добавитьслово", # основные "addword", "добавитьслово", # основные
"фввцщкв", "lj,fdbnmckjdj", # раскладка "фввцщкв", "lj,fdbnmckjdj", # раскладка
"aw", "addw", "добслово", # сокращения "aw", "addw", "добслово", "word", # сокращения
], ],
"addlemma": [ "addlemma": [
"addlemma", "добавитьлемму", # основные "addlemma", "добавитьлемму", # основные
"фввдуььф", "lj,fdbnmktve", # раскладка "фввдуььф", "lj,fdbnmktve", # раскладка
"al", "addl", "доблемму", # сокращения "al", "addl", "доблемму", "lemma", "lem", "lema",
], ],
"addpart": [ "addpart": [
"addpart", "добавитьчасть", # основные "addpart", "добавитьчасть", # основные
"фввзфке", "lj,fdbnmxfcnm", # раскладка "фввзфке", "lj,fdbnmxfcnm", # раскладка
"ap", "addp", "добчасть", # сокращения "ap", "addp", "добчасть", "part",
], ],
# ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ==================== # ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ====================
"addtempword": [ "addtempword": [
"addtempword", "добавитьвремслово", # основные "addtempword", "добавитьвремслово", # основные
"фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка "фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка
"atw", "addtw", "темпслово", # сокращения "atw", "addtw", "темпслово", "addtword", "tempword", "tword",
], ],
"addtemplemma": [ "addtemplemma": [
"addtemplemma", "добавитьвремлемму", # основные "addtemplemma", "добавитьвремлемму", # основные
"фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка "фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка
"atl", "addtl", "темплемму", # сокращения "atl", "addtl", "темплемму", "addtlem", "addtemplem",
], ],
# ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== # ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
"addexcept": [ "addexcept": [
"addexcept", "добавитьисключение", # основные "addexcept", "добавитьисключение", # основные
"фввучсузе", "lj,fdbnmbcrkx", # раскладка "фввучсузе", "lj,fdbnmbcrkx", # раскладка
"axc", "addwhite", "искл", # сокращения "axc", "addwhite", "искл", "except", "white",
], ],
# ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ==================== # ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ====================
"remword": [ "remword": [
"remword", "удалитьслово", # основные "remword", "удалитьслово", # основные
"кутцщкв", "elfkbnmckjdj", # раскладка "кутцщкв", "elfkbnmckjdj", # раскладка
"rw", "delword", "dw", "удслово", # сокращения "rw", "delword", "dw", "удслово",
], ],
"remlemma": [ "remlemma": [
"remlemma", "удалитьлемму", # основные "remlemma", "удалитьлемму", # основные
"кутдуььф", "elfkbnmktve", # раскладка "кутдуььф", "elfkbnmktve", # раскладка
"rl", "dellemma", "dl", "удлемму", # сокращения "rl", "dellemma", "dl", "удлемму",
], ],
"rempart": [ "rempart": [
"rempart", "удалитьчасть", # основные "rempart", "удалитьчасть", # основные
"кутзфке", "elfkbnmxfcnm", # раскладка "кутзфке", "elfkbnmxfcnm", # раскладка
"rp", "delpart", "dp", "удчасть", # сокращения "rp", "delpart", "dp", "удчасть",
], ],
# ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ==================== # ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ====================
"remtempword": [ "remtempword": [
"remtempword", "удалитьвремслово", # основные "remtempword", "удалитьвремслово", # основные
"кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка "кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка
"rtw", "deltw", "удтемпслово", # сокращения "rtw", "deltw", "удтемпслово", "rtword", "rtempword",
], ],
"remtemplemma": [ "remtemplemma": [
"remtemplemma", "удалитьвремлемму", # основные "remtemplemma", "удалитьвремлемму", # основные
"кутеуьздуььф", "elfkbnmdhtvktve", # раскладка "кутеуьздуььф", "elfkbnmdhtvktve", # раскладка
"rtl", "deltl", "удтемплемму", # сокращения "rtl", "deltl", "удтемплемму", "rtlemma", "rtemplemma", "rtlem",
], ],
# ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ==================== # ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
"remexcept": [ "remexcept": [
"remexcept", "удалитьисключение", # основные "remexcept", "удалитьисключение", # основные
"кутучсузе", "elfkbnmbcrkx", # раскладка "кутучсузе", "elfkbnmbcrkx", # раскладка
"rxc", "remwhite", "удискл", # сокращения "rxc", "remwhite", "удискл",
], ],
# ==================== КОНФЛИКТНЫЕ СЛОВА ==================== # ==================== КОНФЛИКТНЫЕ СЛОВА ====================
"addconflictword": [ "addconflictword": [
"addconflictword", "добавитьконфликт", # основные "addconflictword", "добавитьконфликт", # основные
"фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка "фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка
"acw", "addcw", "конфслово", # сокращения "acw", "addcw", "конфслово", "conflictword",
], ],
"addconflictlemma": [ "addconflictlemma": [
"addconflictlemma", "добавитьконфлемму", # основные "addconflictlemma", "добавитьконфлемму", # основные
"фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка "фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка
"acl", "addcl", "конфлемму", # сокращения "acl", "addcl", "конфлемму", "conflictlemma",
], ],
"remconflictword": [ "remconflictword": [
"remconflictword", "удалитьконфликт", # основные "remconflictword", "удалитьконфликт", # основные
"кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка "кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка
"rcw", "delcw", "удконфликт", # сокращения "rcw", "delcw", "удконфликт",
], ],
"remconflictlemma": [ "remconflictlemma": [
"remconflictlemma", "удалитьконфлемму", # основные "remconflictlemma", "удалитьконфлемму", # основные
"кутсщтakшседуььф", "elfkbnmrjyaktve", # раскладка "кутсщтакшседуььф", "elfkbnmrjyaktve", # раскладка
"rcl", "delcl", "удконфлемму", # сокращения "rcl", "delcl", "удконфлемму",
], ],
# ==================== РЕЖИМ АНТИКОНФЛИКТА ==================== # ==================== РЕЖИМ АНТИКОНФЛИКТА ====================
"stopconflict": [ "stopconflict": [
"stopconflict", "стопконфликт", # основные "stopconflict", "стопконфликт", # основные
"cnjgsщтakшse", "cnjzrjyakbrn", # раскладка "cnjgsщтакшse", "cnjzrjyakbrn", # раскладка
"sconf", "sc", "стопконф", # сокращения "sconf", "sc", "стопконф", "stopconf",
], ],
"unstopconflict": [ "unstopconflict": [
"unstopconflict", "отменаконфликта", # основные "unstopconflict", "отменаконфликта", # основные
"eycnjgsщтakшse", "jnvtyf", # раскладка "eycnjgsщтакшse", "jnvtyf", # раскладка
"usconf", "usc", "откконф", # сокращения "usconf", "usc", "откконф", "unstopconf",
], ],
"conflictstatus": [ "conflictstatus": [
"conflictstatus", "статусконфликта", # основные "conflictstatus", "статусконфликта", # основные
"сщтakшseыефnec", "cnfnec", # раскладка "сщтакшseыефnec", "cnfnec", # раскладка
"cstatus", "cs", "статконф", # сокращения "cstatus", "cs", "статконф", "confstat",
], ],
# ==================== РЕЖИМ ТИШИНЫ ==================== # ==================== РЕЖИМ ТИШИНЫ ====================
"silence": [ "silence": [
"silence", "тишина", "мут", # основные "silence", "тишина", # основные
"ышдутсу", "nbibyf", "ven", # раскладка "ышдутсу", "nbibyf", # раскладка
"sil", "mute", "quiet", "тиш", # сокращения "sl", "sil", "mute", "quiet", "тиш", "ven",
], ],
"unsilence": [ "unsilence": [
"unsilence", "отменатишины", # основные "unsilence", "отменатишины", # основные
"eтышдутсу", "jnvtyf", # раскладка "eышдутсу", "jnvtyf", # раскладка
"unsil", "unmute", "откмут", # сокращения "unsil", "unmute", "откмут", "usl", "unsl",
], ],
"silencestatus": [ "silencestatus": [
"silencestatus", "статустишины", # основные "silencestatus", "статустишины", # основные
"ышдутсуыефnec", "cnfnec", # раскладка "ышдутсуыефnec", "cnfnec", # раскладка
"sstatus", "ss", "статтиш", # сокращения "sstatus", "ss", "статтиш",
], ],
"extend_silence": [ "extend_silence": [
"extend_silence", "продлитьтишину", # основные "extend_silence", "продлитьтишину", # основные
"уче_ышдутсу", "ghjlkbnmnbibyet", # раскладка "ex_ышдутсу", "ghjlkbnmnbibyet", # раскладка
"exsil", "exs", "продтиш", # сокращения "exsil", "exs", "продтиш",
], ],
# ==================== АДМИНИСТРАТОРЫ ==================== # ==================== АДМИНИСТРАТОРЫ ====================
"addadmin": [ "addadmin": [
"addadmin", "добавитьадмина", # основные "addadmin", "добавитьадмина", # основные
"фввфвьшт", "lj,fdbnmflvbyf", # раскладка "фввфвьшт", "lj,fdbnmflvbyf", # раскладка
"aa", "addadm", "добадм", # сокращения "aa", "addadm", "добадм",
], ],
"remadmin": [ "remadmin": [
"remadmin", "удалитьадмина", # основные "remadmin", "удалитьадмина", # основные
"кутфвьшт", "elfkbnmflvbyf", # раскладка "кутфвьшт", "elfkbnmflvbyf", # раскладка
"ra", "remadm", "deladmin", "удадм", # сокращения "ra", "remadm", "deladmin", "удадм",
], ],
"listadmins": [ "listadmins": [
"listadmins", "списокадминов", # основные "listadmins", "списокадминов", # основные
"дшыефвьшты", "cgbcjrflvbyjd", # раскладка "дшыефвьшты", "cgbcjrflvbyjd", # раскладка
"admins", "adm", "adminlist", "адм", # сокращения "admins", "adm", "adminlist", "адм", "дшыефвь", "listadm", "la",
], ],
"adminhelp": [ "adminhelp": [
"adminhelp", "помощьадмину", # основные "adminhelp", "помощьадмину", # основные
"фвьштрудз", "gjvjomflvbyt", # раскладка "фвьштрудз", "gjvjomflvbyt", # раскладка
"admhelp", "ah", "хелпадм", # сокращения "admhelp", "ah", "хелпадм",
], ],
"checkadmin": [ "checkadmin": [
"checkadmin", "проверкаадмина", # основные "checkadmin", "проверкаадмина", # основные
"сруслфвьшт", "ghjdthrf", # раскладка "сруслфвьшт", "ghjdthrf", # раскладка
"isadmin", "ca", "провадм", # сокращения "isadmin", "ca", "провадм", "checkadm",
], ],
# ==================== ПРОСМОТР ==================== # ==================== ПРОСМОТР ====================
"list": [ "list": [
"listwords", "списокслов", # основные "listwords", "списокслов", "listword", # основные
"дшыецщквы", "cgbcjrckjd", # раскладка "дшыецщквы", "cgbcjrckjd", # раскладка
"lw", "list", "дшые", "words", "слова", # сокращения "lw", "list", "дшые", "words", "слова", "l",
], ],
"listlemmas": [ "listlemmas": [
"listlemmas", "списоклемм", # основные "listlemmas", "списоклемм", # основные
"дшыедуььфы", "cgbcjrktv", # раскладка "дшыедуььфы", "cgbcjrktv", # раскладка
"ll", "lemmas", "леммы", # сокращения "ll", "lemmas", "леммы",
], ],
"listparts": [ "listparts": [
"listparts", "списокчастей", # основные "listparts", "списокчастей", # основные
"дшыезфкеы", "cgbcjrxfcntq", # раскладка "дшыезфкеы", "cgbcjrxfcntq", # раскладка
"lp", "parts", "части", # сокращения "lp", "parts", "части",
], ],
"listexcept": [ "listexcept": [
"listexcept", "списокисключений", # основные "listexcept", "списокисключений", # основные
"дшыеучсузе", "cgbcjrbcrkx", # раскладка "дшыеучсузе", "cgbcjrbcrkx", # раскладка
"lxc", "except", "white", "искл", # сокращения "lxc", "except", "white", "искл",
], ],
"listconflict": [ "listconflict": [
"listconflict", "списокконфликтов", # основные "listconflict", "списокконфликтов", # основные
"дшыесщтakшse", "cgbcjrrjyakbrnjd", # раскладка "дшыесщтакшse", "cgbcjrrjyakbrnjd", # раскладка
"lc", "conflict", "конф", # сокращения "lc", "conflict", "конф",
], ],
# ==================== СТАТИСТИКА ==================== # ==================== СТАТИСТИКА ====================
"userstats": [ "userstats": [
"userstats", "статистикапользователя", # основные "userstats", "статистикапользователя", # основные
"ecthыефnы", "cnfnbcnbrf", # раскладка "ecthыефnы", "cnfnbcnbrf", # раскладка
"ustat", "us", "статюзер", # сокращения "ustat", "us", "статюзер",
], ],
"resetstats": [ "resetstats": [
"resetstats", "сброситьстат", # основные "resetstats", "сброситьстат", # основные
"кыуеыефnы", "c,hjcbnm", # раскладка "кыуеыефnы", "c,hjcbnm", # раскладка
"rstats", "clearstats", "сброс", # сокращения "rstats", "clearstats", "сброс",
], ],
# ==================== ИНФОРМАЦИЯ ==================== # ==================== ИНФОРМАЦИЯ ====================
"id": [ "id": [
"id", "айди", "инфо", # основные "id", "айди", "инфо", # основные
"шв", "fqlb", "byaj", # раскладка "шв", "fqlb", "byaj", # раскладка
"info", "me", "мои", # сокращения "info", "me", "мои",
], ],
"myid": [ "myid": [
"myid", "мойайди", # основные "myid", "мойайди", # основные
"ьншв", "vjqfqlb", # раскладка "ьншв", "vjqfqlb", # раскладка
"mid", "мид", # сокращения "mid", "мид",
], ],
"chatid": [ "chatid": [
"chatid", "айдичата", # основные "chatid", "айдичата", # основные
"срфешв", "fqlbxfnf", # раскладка "срфешв", "fqlbxfnf", # раскладка
"cid", "чатид", # сокращения "cid", "чатид",
], ],
# ==================== РЕПОРТЫ ==================== # ==================== РЕПОРТЫ ====================
"report": [ "report": [
"report", "репорт", "жалоба", # основные "report", "репорт", "жалоба", # основные
"кузщке", "htgjhn", ";fkj,f", # раскладка "кузщке", "htgjhn", ";fkj,f", # раскладка
"rep", "r", "жал", # сокращения "rep", "r", "жал",
], ],
"reporthelp": [ "reporthelp": [
"reporthelp", "помощьрепорт", # основные "reporthelp", "помощьрепорт", # основные
"кузщкерудз", "gjvjomhtgjhn", # раскладка "кузщкерудз", "gjvjomhtgjhn", # раскладка
"rephelp", "rh", "хелпреп", # сокращения "rephelp", "rh", "хелпреп",
], ],
"reportstats": [ "reportstats": [
"reportstats", "статистикарепортов", # основные "reportstats", "статистикарепортов", # основные
"кузщкеыефnы", "cnfnbcnbrf", # раскладка "кузщкеыефnы", "cnfnbcnbrf", # раскладка
"rstat", "rs", "статреп", # сокращения "rstat", "rs", "статреп",
], ],
"checkreport": [ "checkreport": [
"checkreport", "проверкарепорта", # основные "checkreport", "проверкарепорта", # основные
"сруслкузщке", "ghjdthrf", # раскладка "сруслкузщке", "ghjdthrf", # раскладка
"crep", "cr", "провреп", # сокращения "crep", "cr", "провреп",
], ],
"closereport": [ "closereport": [
"closereport", "закрытьрепорт", # основные "closereport", "закрытьрепорт", # основные
"сдщыукузщке", "pfrhsnm", # раскладка "сдщыукузщке", "pfrhsnm", # раскладка
"close", "cl", "закреп", # сокращения "close", "cl", "закреп",
], ],
"banreport": [ "banreport": [
"banreport", "забанитьрепорт", # основные "banreport", "забанитьрепорт", # основные
"фтшкузщке", "pf,fybnm", # раскладка "фтшкузщке", "pf,fybnm", # раскладка
"banrep", "br", "банреп", # сокращения "banrep", "br", "банреп",
], ],
# ==================== ЭМОДЗИ ==================== # ==================== ЭМОДЗИ ====================
"emoji": [ "emoji": [
"emoji", "эмодзи", # основные "emoji", "эмодзи", # основные
"уьщош", "'vjlpb", # раскладка "уьщош", "'vjlpb", # раскладка
"em", "emj", "эм", # сокращения "em", "emj", "эм",
], ],
"emojihelp": [ "emojihelp": [
"emojihelp", "помощьэмодзи", # основные "emojihelp", "помощьэмодзи", # основные
"уьщошрудз", "gjvjom'vjlpb", # раскладка "уьщошрудз", "gjvjom'vjlpb", # раскладка
"emhelp", "emh", "хелпэм", # сокращения "emhelp", "emh", "хелпэм",
], ],
# ==================== СИСТЕМНЫЕ ==================== # ==================== СИСТЕМНЫЕ ====================
"ping": [ "ping": [
"ping", "пинг", # основные "ping", "пинг", # основные
"зштп", "gbyp", # раскладка "зштп", "gbyp", # раскладка
"p", "пн", # сокращения "p", "пн",
], ],
"version": [ "version": [
"version", "версия", # основные "version", "версия", # основные
"дукышщт", "dthcbz", # раскладка "дукышщт", "dthcbz", # раскладка
"ver", "v", "вер", # сокращения "ver", "v",
], ],
"reload": [ "reload": [
"reload", "перезагрузка", # основные "reload", "перезагрузка", # основные
"кудщфв", "gthtpfuheprf", # раскладка "кудщфв", "gthtpfuheprf", # раскладка
"rl", "restart", "рест", # сокращения "rl", "restart", "рест",
], ],
"logs": [ "logs": [
"logs", "логи", # основные "logs", "логи", # основные
"дщпы", "kjub", # раскладка "дщпы", "kjub", # раскладка
"log", "l", "лог", # сокращения "log", "l",
],
"cancel": [
"cancel", "c", # основные
"отменить", "сфтскд", # раскладка
],
"redactcomment": [
"redactcomment", "editcomment", "комментарии", "redc", # основные + сокращения
"кувфсщтскщйьщк", "gfhthfyjdfz", # раскладка
"redcom", "editcom", "коммент", "rc", # дополнения
], ],
"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
} }

View File

@@ -26,6 +26,7 @@ class _Settings(BaseSettings):
# Параметры сообщений # Параметры сообщений
PARSE_MODE: str = "HTML" PARSE_MODE: str = "HTML"
PREFIX: str = "/!.&?" PREFIX: str = "/!.&?"
LOG_LEVEL: str = "TRACE"
# Разрешения и логирование # Разрешения и логирование
BOT_EDIT: bool = False BOT_EDIT: bool = False
@@ -55,7 +56,6 @@ class _Settings(BaseSettings):
# Идентификаторы # Идентификаторы
OWNER_ID: list[int] = [6751720805] OWNER_ID: list[int] = [6751720805]
ADMIN_ID: list[int] = [] ADMIN_ID: list[int] = []
ADMIN_CHAT_ID: int = 0
# Настройки бота # Настройки бота
BOT_NAME: str = "Бот" BOT_NAME: str = "Бот"
@@ -89,6 +89,24 @@ class _Settings(BaseSettings):
description="URL фото по умолчанию" description="URL фото по умолчанию"
) )
# ================= АДМИНСКИЕ УВЕДОМЛЕНИЯ =================
# ID чата для уведомлений о банвордах/спаме
ADMIN_CHAT_ID: Optional[int] = -1002522785068
# ID топика для уведомлений о банвордах (опционально)
# Если None - уведомления идут в основной чат (General)
ADMIN_THREAD_ID: Optional[int] = None # Например: 12345
# ================= РЕПОРТЫ =================
# ID чата для репортов (если None, репорты идут владельцам в ЛС)
REPORT_CHAT_ID: Optional[int] = ADMIN_CHAT_ID # Можно тот же чат или другой
# ID топика для репортов (опционально)
# Если None - репорты идут в основной чат (General)
REPORT_THREAD_ID: Optional[int] = None # ✅ ИСПРАВЛЕНО: было ADMIN_THREAD_ID
# Права администратора # Права администратора

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from middleware.loggers import logger from middleware.loggers import logger
from .database import Database, get_db from .database import Database, get_db
from .repository import BanWordsRepository from .repository import BanWordsRepository
from .models import BanWordType, SpamStat, SpamLog, TempBanWord from .models import BanWordType, SpamStat, SpamLog, TempBanWord, AutoComment
from sqlalchemy import select, delete, func, desc from sqlalchemy import select, delete, func, desc
@@ -43,6 +43,7 @@ class BanWordsManager:
async def init(self) -> None: async def init(self) -> None:
"""Инициализирует базу данных и загружает кэш""" """Инициализирует базу данных и загружает кэш"""
await self.db.init() await self.db.init()
await self.init_default_bot_settings() # ← добавлено
await self.refresh_cache() await self.refresh_cache()
logger.info("BanWordsManager инициализирован", log_type="DATABASE") logger.info("BanWordsManager инициализирован", log_type="DATABASE")
@@ -335,7 +336,6 @@ class BanWordsManager:
now = datetime.now().timestamp() now = datetime.now().timestamp()
if now >= silence_until: if now >= silence_until:
# Время истекло - удаляем настройку
await self.disable_silence_mode() await self.disable_silence_mode()
return False return False
@@ -381,7 +381,6 @@ class BanWordsManager:
now = datetime.now().timestamp() now = datetime.now().timestamp()
if now >= conflict_until: if now >= conflict_until:
# Время истекло
await self.disable_conflict_mode() await self.disable_conflict_mode()
return False return False
@@ -433,7 +432,6 @@ class BanWordsManager:
"""Получает общую статистику""" """Получает общую статистику"""
db_stats = await self.repo.get_stats() db_stats = await self.repo.get_stats()
# Добавляем информацию о кэше
cache_info = { cache_info = {
'cache_active': self._cache_banwords is not None, 'cache_active': self._cache_banwords is not None,
'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None 'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None
@@ -480,7 +478,6 @@ class BanWordsManager:
""" """
async with self.db.get_session() as session: async with self.db.get_session() as session:
try: try:
# Группируем по matched_word и считаем количество
query = select( query = select(
SpamLog.matched_word, SpamLog.matched_word,
SpamLog.match_type, SpamLog.match_type,
@@ -497,7 +494,6 @@ class BanWordsManager:
result = await session.execute(query) result = await session.execute(query)
rows = result.all() rows = result.all()
# Форматируем результат
top_words = [] top_words = []
for row in rows: for row in rows:
top_words.append({ top_words.append({
@@ -531,7 +527,6 @@ class BanWordsManager:
try: try:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Ищем истёкшие временные слова
query = select(TempBanWord).where( query = select(TempBanWord).where(
TempBanWord.expires_at < now TempBanWord.expires_at < now
) )
@@ -541,23 +536,18 @@ class BanWordsManager:
if not expired_words: if not expired_words:
return 0 return 0
# Собираем информацию для логирования
expired_info = [] expired_info = []
for word in expired_words: for word in expired_words:
expired_info.append({ expired_info.append({
'word': word.word, 'word': word.word,
'type': word.word_type.value, 'type': word.type.value, # ← ИСПРАВЛЕНО: было word.word_type.value
'expires_at': word.expires_at 'expires_at': word.expires_at
}) })
await session.delete(word) await session.delete(word)
# Сохраняем изменения
await session.commit() await session.commit()
# Обновляем кеш
await self.refresh_cache() await self.refresh_cache()
# Логируем подробности
logger.info( logger.info(
f"Удалено {len(expired_words)} истёкших временных банвордов", f"Удалено {len(expired_words)} истёкших временных банвордов",
log_type="DATABASE" log_type="DATABASE"
@@ -608,7 +598,6 @@ class BanWordsManager:
""" """
async with self.db.get_session() as session: async with self.db.get_session() as session:
try: try:
# Удаляем все записи
await session.execute(delete(SpamLog)) await session.execute(delete(SpamLog))
await session.commit() await session.commit()
@@ -629,32 +618,30 @@ class BanWordsManager:
""" """
Получает настройки автокомментариев для канала. Получает настройки автокомментариев для канала.
Args: ВАЖНО: возвращает сохранённые поля даже когда is_enabled=False,
channel_id: ID канала чтобы UI/preview показывали реальную конфигурацию.
Returns:
dict: Настройки или значения по умолчанию
""" """
from configs import settings from configs import settings
auto_comment = await self.repo.get_auto_comment(channel_id) auto_comment = await self.repo.get_auto_comment(channel_id)
if auto_comment and auto_comment.is_enabled: defaults = {
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, 'text': settings.AUTO_COMMENT_TEXT,
'button_text': settings.AUTO_COMMENT_BUTTON_TEXT, 'button_text': settings.AUTO_COMMENT_BUTTON_TEXT,
'button_url': settings.AUTO_COMMENT_BUTTON_URL, 'button_url': settings.AUTO_COMMENT_BUTTON_URL,
'photo_url': settings.AUTO_COMMENT_PHOTO_URL, 'photo_url': settings.AUTO_COMMENT_PHOTO_URL,
'is_enabled': False, # По умолчанию выключено 'is_enabled': False,
}
if not auto_comment:
return defaults
return {
'text': auto_comment.text if auto_comment.text is not None else defaults['text'],
'button_text': auto_comment.button_text if auto_comment.button_text is not None else defaults['button_text'],
'button_url': auto_comment.button_url if auto_comment.button_url is not None else defaults['button_url'],
'photo_url': auto_comment.photo_url if auto_comment.photo_url is not None else defaults['photo_url'],
'is_enabled': bool(auto_comment.is_enabled),
} }
async def save_auto_comment_settings( async def save_auto_comment_settings(
@@ -715,6 +702,141 @@ class BanWordsManager:
channel_id, 'photo_url', photo_url, updated_by channel_id, 'photo_url', photo_url, updated_by
) )
async def log_report(
self,
report_id: str,
reporter_id: int,
reporter_username: Optional[str],
reported_user_id: int,
reported_username: Optional[str],
chat_id: int,
chat_title: Optional[str],
message_id: int,
message_thread_id: Optional[int],
message_text: Optional[str],
reason: str
) -> bool:
"""Логирует репорт в БД"""
return await self.repo.log_report(
report_id=report_id,
reporter_id=reporter_id,
reporter_username=reporter_username,
reported_user_id=reported_user_id,
reported_username=reported_username,
chat_id=chat_id,
chat_title=chat_title,
message_id=message_id,
message_thread_id=message_thread_id,
message_text=message_text,
reason=reason
)
# ==================== ✅ КАНАЛЫ АВТОКОММЕНТАРИЕВ ====================
async def add_auto_comment_channel(self, channel_id: int, added_by: int) -> bool:
"""✅ Добавляет новый канал в БД"""
async with self.db.get_session() as session:
try:
new_channel = AutoComment(
channel_id=channel_id,
text="",
button_text="",
button_url="",
photo_url="",
is_enabled=False,
updated_by=added_by,
)
session.add(new_channel)
await session.commit()
await session.refresh(new_channel)
logger.info(f"✅ Канал добавлен: {channel_id}", log_type="CHANNEL")
return True
except Exception as e:
await session.rollback()
logger.error(f"Ошибка добавления канала {channel_id}: {e}", log_type="CHANNEL")
return False
async def get_auto_comment_channels(self) -> List[int]:
"""✅ Возвращает все channel_id из БД"""
async with self.db.get_session() as session:
result = await session.execute(select(AutoComment.channel_id).distinct())
return [row[0] for row in result.fetchall()]
async def delete_auto_comment(self, channel_id: int) -> bool:
"""✅ Удаляет настройки канала"""
async with self.db.get_session() as session:
try:
result = await session.execute(
delete(AutoComment).where(AutoComment.channel_id == channel_id)
)
if result.rowcount > 0:
await session.commit()
logger.info(f"✅ Канал удален: {channel_id}", log_type="CHANNEL")
return True
await session.rollback()
return False
except Exception as e:
await session.rollback()
logger.error(f"Ошибка удаления канала {channel_id}: {e}", log_type="CHANNEL")
return False
# ==================== BOT SETTINGS (замена .env) ====================
async def get_bot_settings(self) -> dict:
"""Получает все настройки бота из БД"""
settings = {
'admin_chat_id': await self.repo.get_setting("admin_chat_id"),
'admin_thread_id': await self.repo.get_setting("admin_thread_id"),
'report_chat_id': await self.repo.get_setting("report_chat_id"),
'report_thread_id': await self.repo.get_setting("report_thread_id"),
}
return {k: v for k, v in settings.items() if v is not None}
async def set_bot_setting(self, key: str, value: Optional[str]) -> bool:
"""
Сохраняет настройку бота в БД
Args:
key: admin_chat_id, admin_thread_id, report_chat_id, report_thread_id
value: str или None/null
Returns:
bool: True если сохранено
"""
if value is None:
return await self.repo.delete_setting(key)
else:
return await self.repo.set_setting(key, value)
async def get_bot_setting(self, key: str) -> Optional[str]:
"""Получает ОДНУ настройку бота"""
settings = await self.get_bot_settings()
return settings.get(key)
async def init_default_bot_settings(self) -> None:
"""Инициализирует настройки по умолчанию из .env, только если они ещё не установлены"""
try:
from configs import settings
defaults = {
"admin_chat_id": getattr(settings, 'ADMIN_CHAT_ID', None),
"admin_thread_id": str(getattr(settings, 'ADMIN_THREAD_ID', None)) if getattr(settings, 'ADMIN_THREAD_ID', None) else None,
"report_chat_id": getattr(settings, 'REPORT_CHAT_ID', None),
"report_thread_id": str(getattr(settings, 'REPORT_THREAD_ID', None)) if getattr(settings, 'REPORT_THREAD_ID', None) else None,
}
for key, value in defaults.items():
if value is not None: # В .env значение задано
existing = await self.get_bot_setting(key)
if existing is None:
await self.set_bot_setting(key, str(value))
logger.debug(f"Установлена настройка {key} из .env", log_type="SETTINGS")
else:
logger.debug(f"Настройка {key} уже существует ({existing}), пропускаем", log_type="SETTINGS")
logger.info("✅ Настройки бота инициализированы из .env (существующие сохранены)", log_type="SETTINGS")
except Exception as e:
logger.warning(f"Не удалось инициализировать настройки из .env: {e}", log_type="SETTINGS")
# Глобальный экземпляр менеджера # Глобальный экземпляр менеджера
_manager_instance: Optional[BanWordsManager] = None _manager_instance: Optional[BanWordsManager] = None

View File

@@ -19,7 +19,8 @@ __all__ = (
"Setting", "Setting",
"SpamStat", "SpamStat",
"SpamLog", "SpamLog",
"AutoComment", "AutoComment",
"Report",
) )
@@ -294,3 +295,68 @@ class AutoComment(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>" return f"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>"
class Report(Base):
"""
Модель для хранения статистики репортов.
Attributes:
id: Уникальный ID репорта
report_id: Строковый ID репорта (timestamp)
reporter_id: ID пользователя, который пожаловался
reporter_username: Username жалобщика
reported_user_id: ID пользователя, на которого пожаловались
reported_username: Username нарушителя
chat_id: ID чата, где произошло нарушение
chat_title: Название чата
message_id: ID сообщения-нарушения
message_thread_id: ID топика (если есть)
message_text: Текст сообщения (до 500 символов)
reason: Причина жалобы
status: Статус репорта (pending, closed, banned, deleted)
processed_by: ID админа, который обработал
created_at: Дата создания репорта
processed_at: Дата обработки
"""
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
report_id: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
# Информация о жалобщике
reporter_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
reporter_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Информация о нарушителе
reported_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
reported_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Информация о чате и сообщении
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
chat_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
message_thread_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
message_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Причина и статус
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(
String(20),
default="pending",
nullable=False,
index=True
) # pending, closed, banned, deleted
# Обработка
processed_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True
)
processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
def __repr__(self) -> str:
return f"<Report(id={self.report_id}, reporter={self.reporter_id}, reported={self.reported_user_id})>"

View File

@@ -157,12 +157,6 @@ class BanWordsRepository:
return set() return set()
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]: async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
"""
Получает все банворды, сгруппированные по типам.
Returns:
dict: {BanWordType: Set[str]}
"""
result = { result = {
BanWordType.SUBSTRING: set(), BanWordType.SUBSTRING: set(),
BanWordType.LEMMA: set(), BanWordType.LEMMA: set(),
@@ -170,19 +164,28 @@ class BanWordsRepository:
BanWordType.CONFLICT_SUBSTRING: set(), BanWordType.CONFLICT_SUBSTRING: set(),
BanWordType.CONFLICT_LEMMA: set(), BanWordType.CONFLICT_LEMMA: set(),
} }
try: try:
async with self.db.get_session() as session: async with self.db.get_session() as session:
banwords = await session.execute(select(BanWord)) banwords = await session.execute(select(BanWord))
loaded = 0
for banword in banwords.scalars(): for banword in banwords.scalars():
result[banword.type].add(banword.word) try:
word_type = (
except Exception as e: banword.type
logger.error( if isinstance(banword.type, BanWordType)
f"Ошибка получения всех банвордов: {e}", else BanWordType(banword.type.casefold())
)
if word_type in result:
result[word_type].add(banword.word)
loaded += 1
except ValueError:
logger.warning(
f"Неизвестный тип: '{banword.type}' для '{banword.word}'",
log_type="DATABASE" log_type="DATABASE"
) )
logger.info(f"✅ Кэш загружен: {loaded} банвордов", log_type="DATABASE")
except Exception as e:
logger.error(f"❌ get_all_banwords: {e}", log_type="DATABASE")
return result return result
async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]: async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]:
@@ -344,8 +347,14 @@ class BanWordsRepository:
) )
) )
for temp_banword in temp_banwords.scalars(): for temp_banword in temp_banwords.scalars():
if temp_banword.type in result: word_type = (
result[temp_banword.type].add(temp_banword.word) temp_banword.type
if isinstance(temp_banword.type, BanWordType)
else BanWordType(temp_banword.type.casefold())
)
if word_type in result:
result[word_type].add(temp_banword.word)
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -1046,3 +1055,336 @@ class BanWordsRepository:
log_type="DATABASE" log_type="DATABASE"
) )
return False return False
# === REPORTS ===
async def log_report(
self,
report_id: str,
reporter_id: int,
reporter_username: Optional[str],
reported_user_id: int,
reported_username: Optional[str],
chat_id: int,
chat_title: Optional[str],
message_id: int,
message_thread_id: Optional[int],
message_text: Optional[str],
reason: str
) -> bool:
"""
Сохраняет репорт в БД.
Args:
report_id: Уникальный ID репорта
reporter_id: ID жалобщика
reporter_username: Username жалобщика
reported_user_id: ID нарушителя
reported_username: Username нарушителя
chat_id: ID чата
chat_title: Название чата
message_id: ID сообщения
message_thread_id: ID топика
message_text: Текст сообщения
reason: Причина жалобы
Returns:
bool: True если успешно
"""
try:
from .models import Report # Импорт здесь, чтобы избежать циклических импортов
async with self.db.get_session() as session:
report = Report(
report_id=report_id,
reporter_id=reporter_id,
reporter_username=reporter_username,
reported_user_id=reported_user_id,
reported_username=reported_username,
chat_id=chat_id,
chat_title=chat_title,
message_id=message_id,
message_thread_id=message_thread_id,
message_text=message_text[:500] if message_text else None,
reason=reason
)
session.add(report)
await session.commit()
logger.info(
f"Репорт #{report_id} сохранён в БД",
log_type="DATABASE"
)
return True
except Exception as e:
logger.error(
f"Ошибка сохранения репорта: {e}",
log_type="DATABASE"
)
return False
async def update_report_status(
self,
report_id: str,
status: str,
processed_by: int
) -> bool:
"""
Обновляет статус репорта.
Args:
report_id: ID репорта
status: Новый статус (closed, banned, deleted)
processed_by: ID админа
Returns:
bool: True если успешно
"""
try:
from .models import Report
async with self.db.get_session() as session:
result = await session.execute(
select(Report).where(Report.report_id == report_id)
)
report = result.scalar_one_or_none()
if not report:
return False
report.status = status
report.processed_by = processed_by
report.processed_at = datetime.now(timezone.utc)
await session.commit()
logger.info(
f"Репорт #{report_id} обновлён: статус={status}",
log_type="DATABASE"
)
return True
except Exception as e:
logger.error(
f"Ошибка обновления статуса репорта: {e}",
log_type="DATABASE"
)
return False
async def get_report_stats(self) -> dict:
"""
Получает общую статистику по репортам.
Returns:
dict: Статистика
"""
try:
from .models import Report
async with self.db.get_session() as session:
# Всего репортов
total_reports = await session.execute(
select(func.count(Report.id))
)
# По статусам
pending_reports = await session.execute(
select(func.count(Report.id)).where(Report.status == "pending")
)
closed_reports = await session.execute(
select(func.count(Report.id)).where(Report.status == "closed")
)
banned_reports = await session.execute(
select(func.count(Report.id)).where(Report.status == "banned")
)
deleted_reports = await session.execute(
select(func.count(Report.id)).where(Report.status == "deleted")
)
return {
'total': total_reports.scalar_one(),
'pending': pending_reports.scalar_one(),
'closed': closed_reports.scalar_one(),
'banned': banned_reports.scalar_one(),
'deleted': deleted_reports.scalar_one(),
}
except Exception as e:
logger.error(
f"Ошибка получения статистики репортов: {e}",
log_type="DATABASE"
)
return {}
async def get_top_reporters(self, limit: int = 10) -> List[tuple[int, str, int]]:
"""
Получает топ жалобщиков.
Args:
limit: Количество записей
Returns:
List[tuple[int, str, int]]: [(user_id, username, count), ...]
"""
try:
from .models import Report
async with self.db.get_session() as session:
result = await session.execute(
select(
Report.reporter_id,
Report.reporter_username,
func.count(Report.id).label('count')
)
.group_by(Report.reporter_id, Report.reporter_username)
.order_by(func.count(Report.id).desc())
.limit(limit)
)
return [
(row.reporter_id, row.reporter_username or f"id{row.reporter_id}", row.count)
for row in result
]
except Exception as e:
logger.error(
f"Ошибка получения топ жалобщиков: {e}",
log_type="DATABASE"
)
return []
async def get_top_reported_users(self, limit: int = 10) -> List[tuple[int, str, int]]:
"""
Получает топ нарушителей.
Args:
limit: Количество записей
Returns:
List[tuple[int, str, int]]: [(user_id, username, count), ...]
"""
try:
from .models import Report
async with self.db.get_session() as session:
result = await session.execute(
select(
Report.reported_user_id,
Report.reported_username,
func.count(Report.id).label('count')
)
.group_by(Report.reported_user_id, Report.reported_username)
.order_by(func.count(Report.id).desc())
.limit(limit)
)
return [
(row.reported_user_id, row.reported_username or f"id{row.reported_user_id}", row.count)
for row in result
]
except Exception as e:
logger.error(
f"Ошибка получения топ нарушителей: {e}",
log_type="DATABASE"
)
return []
async def get_recent_reports(self, limit: int = 20) -> List:
"""
Получает последние репорты.
Args:
limit: Количество записей
Returns:
List[Report]: Список репортов
"""
try:
from .models import Report
async with self.db.get_session() as session:
result = await session.execute(
select(Report)
.order_by(Report.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
except Exception as e:
logger.error(
f"Ошибка получения последних репортов: {e}",
log_type="DATABASE"
)
return []
async def get_user_report_count(self, user_id: int, as_reporter: bool = True) -> int:
"""
Получает количество репортов пользователя.
Args:
user_id: ID пользователя
as_reporter: True - как жалобщик, False - как нарушитель
Returns:
int: Количество репортов
"""
try:
from .models import Report
async with self.db.get_session() as session:
if as_reporter:
result = await session.execute(
select(func.count(Report.id)).where(
Report.reporter_id == user_id
)
)
else:
result = await session.execute(
select(func.count(Report.id)).where(
Report.reported_user_id == user_id
)
)
return result.scalar_one()
except Exception as e:
logger.error(
f"Ошибка подсчёта репортов пользователя: {e}",
log_type="DATABASE"
)
return 0
async def get_setting(self, key: str) -> Optional[str]:
"""Получает значение настройки"""
async with self.db.get_session() as session:
result = await session.get(Setting, key)
return result.value if result else None
async def set_setting(self, key: str, value: str) -> bool:
"""Устанавливает значение настройки"""
async with self.db.get_session() as session:
try:
setting = await session.get(Setting, key)
if setting:
setting.value = value
setting.updated_at = datetime.now()
else:
setting = Setting(key=key, value=value)
session.add(setting)
await session.commit()
return True
except Exception as e:
await session.rollback()
logger.error(f"set_setting {key} failed: {e}", log_type="DATABASE")
return False
async def delete_setting(self, key: str) -> bool:
"""Удаляет настройку"""
async with self.db.get_session() as session:
try:
result = await session.execute(delete(Setting).where(Setting.key == key))
await session.commit()
return result.rowcount > 0
except Exception as e:
await session.rollback()
logger.error(f"delete_setting {key} failed: {e}", log_type="DATABASE")
return False

View File

@@ -1,5 +1,5 @@
""" """
Кастомный логгер с поддержкой декораторов и прямого вызова Кастомный логгер с поддержством декораторов и прямого вызова
""" """
from sys import stderr from sys import stderr
from pathlib import Path from pathlib import Path
@@ -43,7 +43,6 @@ class Logger:
'<cyan>{extra[user]}</cyan> <red>|</red> <level>{message}</level>' '<cyan>{extra[user]}</cyan> <red>|</red> <level>{message}</level>'
) )
def __init__(self, system_name: str = 'PRIMO') -> None: def __init__(self, system_name: str = 'PRIMO') -> None:
""" """
Инициализация логгера. Инициализация логгера.
@@ -58,6 +57,11 @@ class Logger:
""" """
Настройка обработчиков Loguru: консоль и файлы. Настройка обработчиков Loguru: консоль и файлы.
Учитывает переменную LOG_LEVEL из settings.
LOG_LEVEL определяет минимальный уровень для консоли и общего файла,
а также влияет на то, какие отдельные файлы создаются:
создаются только файлы для уровней >= LOG_LEVEL.
Args: Args:
start: Если True, сразу логирует запуск проекта start: Если True, сразу логирует запуск проекта
""" """
@@ -67,6 +71,15 @@ class Logger:
# Полная очистка настроек # Полная очистка настроек
nlogger.remove() nlogger.remove()
# Определяем уровень логирования из настроек
log_level_str = getattr(settings, 'LOG_LEVEL', 'INFO').upper()
# Проверка на допустимость
try:
log_level_no = nlogger.level(log_level_str).no
except ValueError:
log_level_str = 'INFO'
log_level_no = nlogger.level('INFO').no
# Создание директории для файловых логов # Создание директории для файловых логов
log_dir: Path = settings.LOG_DIR log_dir: Path = settings.LOG_DIR
if not log_dir.exists(): if not log_dir.exists():
@@ -78,35 +91,39 @@ class Logger:
sink=stderr, sink=stderr,
format=self._log_format, format=self._log_format,
colorize=True, colorize=True,
level='INFO', level=log_level_str,
filter=lambda rec: rec['extra'].get('log_type') != 'TRACE' filter=lambda rec: rec['extra'].get('log_type') != 'TRACE'
) )
# Файловые логи # Файловые логи
if settings.LOG_FILE: if settings.LOG_FILE:
# Общий лог со всеми уровнями # Общий лог со всеми уровнями (начиная с LOG_LEVEL)
nlogger.add( nlogger.add(
sink=log_dir / 'bot.log', sink=log_dir / 'bot.log',
rotation=settings.LOG_ROTATION, rotation=settings.LOG_ROTATION,
retention=settings.LOG_RETENTION, retention=settings.LOG_RETENTION,
format=self._log_format, format=self._log_format,
level='DEBUG', level=log_level_str,
enqueue=True, enqueue=True,
backtrace=True, backtrace=True,
diagnose=True, diagnose=True,
encoding='utf-8' encoding='utf-8'
) )
# Раздельные логи по уровням # Раздельные логи по уровням создаём только для уровней >= LOG_LEVEL
log_levels = { # Список интересующих нас уровней (в порядке возрастания)
'INFO': 'info.log', level_configs = [
'WARNING': 'warning.log', ('DEBUG', 'debug.log'),
'ERROR': 'error.log', ('INFO', 'info.log'),
'CRITICAL': 'critical.log' ('SUCCESS', 'success.log'),
} ('WARNING', 'warning.log'),
('ERROR', 'error.log'),
('CRITICAL', 'critical.log')
]
for level_name, filename in level_configs:
for level_name, filename in log_levels.items(): level_no = nlogger.level(level_name).no
if level_no >= log_level_no:
nlogger.add( nlogger.add(
sink=log_dir / filename, sink=log_dir / filename,
rotation='10 MB', rotation='10 MB',
@@ -128,7 +145,6 @@ class Logger:
log_type='START' log_type='START'
) )
@staticmethod @staticmethod
def format_user(event: Optional[EventType] = None) -> str: def format_user(event: Optional[EventType] = None) -> str:
""" """
@@ -322,8 +338,8 @@ class Logger:
user: Optional[str] = None, user: Optional[str] = None,
message: Optional[EventType] = None message: Optional[EventType] = None
) -> None: ) -> None:
"""Логирование успешного выполнения (уровень INFO)""" """Логирование успешного выполнения (уровень SUCCESS)"""
self.log_entry('INFO', f"{text}", log_type, user, message) self.log_entry('SUCCESS', text, log_type, user, message)
# ================= КОНТЕКСТНЫЕ МЕНЕДЖЕРЫ ================= # ================= КОНТЕКСТНЫЕ МЕНЕДЖЕРЫ =================