diff --git a/bot/handlers/chl_comment.py b/bot/handlers/chl_comment.py
index 397a471..69949b8 100644
--- a/bot/handlers/chl_comment.py
+++ b/bot/handlers/chl_comment.py
@@ -1,7 +1,9 @@
"""
Автоматическая отправка комментариев под постами канала (через discussion group)
+
+ меню настройки (FSM)
+ полная диагностика
++ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!)
ВАЖНО:
- Комментарии в Telegram — это reply в привязанной группе обсуждений.
@@ -11,7 +13,7 @@
from __future__ import annotations
import time
-from typing import Optional, Tuple, Dict
+from typing import Optional, Tuple, Dict, Any, List
from aiogram import Router, F, Bot
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.state import State, StatesGroup
-from configs import settings
-from database import get_manager
+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",)
@@ -43,7 +46,7 @@ class CommentEditStates(StatesGroup):
waiting_button_text = State()
waiting_button_url = State()
waiting_photo_url = State()
-
+ waiting_add_channel = State() # ✅ ДОБАВИЛИ
# ======================================================================
# HELPERS
@@ -58,25 +61,24 @@ def _defaults() -> dict:
"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:
- """
- Получает настройки автокомментариев для канала из БД.
- Ничего "не затирает": если поля отсутствуют — подставляет дефолты.
- """
- 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
+ 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
+ return (
+ f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n"
+ f"📢 Канал: {channel_id}\n"
+ f"🔘 Статус: {status_emoji}\n\n"
+ f"📝 Текст:\n{text_preview or '(пусто)'}\n\n"
+ f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n"
+ f"🔗 URL: {config.get('button_url') or ''}\n\n"
+ f"🖼 Фото:\n{photo_preview}\n\n"
+ f"💡 Выберите действие:"
+ )
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}:toggle")
ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic")
- ikb.button(text="🗑 Удалить настройки", callback_data=f"edit:{channel_id}:delete")
+ ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ
+ ikb.button(text="🗑 Удалить", callback_data=f"edit:{channel_id}:delete")
ikb.button(text="❌ Закрыть", callback_data="menu:close")
- ikb.adjust(2, 2, 2, 1, 1)
+ ikb.adjust(2, 2, 2, 2, 1)
return ikb
-
-def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder:
+def create_channels_menu(channels: List[int]) -> InlineKeyboardBuilder(): # ✅ List[int]
"""Создаёт меню выбора канала"""
ikb = InlineKeyboardBuilder()
for channel_id in channels:
ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}")
+ ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ
ikb.button(text="❌ Закрыть", callback_data="menu:close")
ikb.adjust(1)
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]:
- 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()
if config.get("button_text") and config.get("button_url"):
keyboard.button(text=config["button_text"], url=config["button_url"])
return full_text, keyboard
-
def _extract_origin_channel_id(message: Message) -> Optional[int]:
- """
- Для auto-forward из привязанного канала Telegram обычно проставляет:
- - message.is_automatic_forward = True
- - message.forward_from_chat = канал
-
- Если forward_from_chat вдруг отсутствует — возвращаем None.
- """
if not message.is_automatic_forward:
return None
-
if message.forward_from_chat and message.forward_from_chat.type == "channel":
return message.forward_from_chat.id
-
return None
-
# Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id)
-_MEDIA_GROUP_SEEN: Dict[Tuple[int, str], float] = {}
+_MEDIA_GROUP_SEEN: Dict[tuple[int, str], float] = {}
_MEDIA_GROUP_TTL_SEC = 45.0
-
def _media_group_should_skip(message: Message) -> bool:
- """
- Возвращает True если это повторная часть альбома и мы уже комментировали.
- Ключ: (chat_id, media_group_id).
- """
if not message.media_group_id:
return False
@@ -145,7 +140,6 @@ def _media_group_should_skip(message: Message) -> bool:
key = (message.chat.id, str(message.media_group_id))
last = _MEDIA_GROUP_SEEN.get(key)
- # чистка старых ключей (лениво)
if len(_MEDIA_GROUP_SEEN) > 500:
cutoff = now - _MEDIA_GROUP_TTL_SEC
for k, t in list(_MEDIA_GROUP_SEEN.items()):
@@ -158,18 +152,72 @@ def _media_group_should_skip(message: Message) -> bool:
_MEDIA_GROUP_SEEN[key] = now
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)
async def auto_comment_from_discussion_forward(message: Message) -> None:
- """
- Ловим пост канала, автоматически пересланный в привязанную группу обсуждений.
- Комментарий отправляем reply на это сообщение => появляется "под постом".
- """
- # 0) Дедуп альбомов
if _media_group_should_skip(message):
logger.debug(
f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}",
@@ -183,7 +231,6 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL"
)
- # 1) Канал-источник
channel_id = _extract_origin_channel_id(message)
if not channel_id:
logger.warning(
@@ -192,23 +239,17 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
)
return
- # 2) Проверка списка каналов
- channels = settings.AUTO_COMMENT_CHANNELS_LIST
+ channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await!
if not channels:
- logger.warning("❌ AUTO_COMMENT_CHANNELS_LIST пуст — нечего обрабатывать", log_type="CHANNEL")
return
-
if channel_id not in channels:
- logger.debug(f"⏭ Channel {channel_id} not in configured list", log_type="CHANNEL")
return
- # 3) /test_comment (если админ запостил команду в канале — она тоже прилетит сюда автофорвардом)
is_test = False
txt = message.text or message.caption or ""
if "/test_comment" in txt:
is_test = True
- # 4) Настройки и статус
try:
config = await get_channel_config(channel_id)
except Exception as e:
@@ -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")
return
- # 5) Формируем и отправляем комментарий (reply в группе)
try:
full_text, keyboard = _build_comment_payload(config)
sent = await message.reply(
text=full_text,
reply_markup=keyboard.as_markup(),
- parse_mode="HTML"
+ parse_mode="HTML",
)
logger.success(
- "✅ Comment sent (discussion reply)\n"
+ f"✅ Comment sent (discussion reply)\n"
f" ├─ Origin channel: {channel_id}\n"
f" ├─ Discussion chat: {message.chat.id}\n"
f" ├─ Forward msg id: {message.message_id}\n"
- f" └─ Comment msg id: {sent.message_id}\n"
+ f" ├─ Comment msg id: {sent.message_id}\n"
f" └─ Test mode: {is_test}",
log_type="CHANNEL"
)
@@ -253,19 +293,60 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
log_type="CHANNEL"
)
except Exception as e:
- logger.error(
- f"❌ Unexpected error while sending comment: {e}",
- log_type="CHANNEL",
- )
-
+ logger.error(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=(
+ "➕ ДОБАВИТЬ КАНАЛ\n\n"
+ "Отправьте ID канала (число с минусом):\n"
+ "Пример: -1003876862007\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. Пример: -1003876862007", 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"✅ Канал добавлен!\n{channel_id}\n/redactcomment", parse_mode="HTML")
+ else:
+ await message.answer(f"❌ Канал {channel_id} уже существует!", parse_mode="HTML")
+
+# ======================================================================
+# DIAGNOSTICS ✅ ФИКС #2
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic"))
async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
- """Запускает полную диагностику канала"""
channel_id = int(callback.data.split(":")[1])
bot: Bot = callback.bot
@@ -273,13 +354,11 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text = "🔍 ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ\n\n"
- # 1) ENV settings
- channels = settings.AUTO_COMMENT_CHANNELS_LIST
+ channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await!
diagnostic_text += "1️⃣ Настройки:\n"
- diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: {channels}\n"
+ diagnostic_text += f" ├─ Каналы из БД: {channels}\n"
diagnostic_text += f" └─ Канал в списке: {'✅' if channel_id in channels else '❌'}\n\n"
- # 2) DB config
diagnostic_text += "2️⃣ База данных:\n"
try:
config = await get_channel_config(channel_id)
@@ -292,7 +371,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
config = None
- # 3) Bot status in channel
diagnostic_text += "3️⃣ Бот в канале:\n"
try:
member = await bot.get_chat_member(channel_id, bot.id)
@@ -313,7 +391,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
- # 4) Linked discussion group
diagnostic_text += "4️⃣ Привязанная группа обсуждений:\n"
linked_chat_id = None
try:
@@ -327,7 +404,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
- # 5) Bot status in discussion group
diagnostic_text += "5️⃣ Бот в группе обсуждений:\n"
if not linked_chat_id:
diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n"
@@ -340,7 +416,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
else:
diagnostic_text += " ├─ Присутствует: ❌\n"
- # can_send_messages бывает не у всех типов, поэтому hasattr
if hasattr(gmember, "can_send_messages"):
diagnostic_text += f" └─ can_send_messages: {'✅' if gmember.can_send_messages else '❌'}\n\n"
else:
@@ -350,33 +425,37 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
except Exception as e:
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
- # Recommendations
diagnostic_text += "💡 Что должно быть для работы:\n"
if channel_id not in channels:
- diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n"
- diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n"
- diagnostic_text += " • Подключите discussion group к каналу\n"
- diagnostic_text += " • Дайте боту право писать в группе обсуждений\n"
- diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n"
-
- await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
+ diagnostic_text += " • Добавьте канал ➕\n"
+ diagnostic_text += (
+ " • Включите автокомментарии (🔄 Переключить)\n"
+ " • Подключите discussion group к каналу\n"
+ " • Дайте боту право писать в группе обсуждений\n"
+ " • Для теста: отправьте пост в канал или пост с /test_comment\n"
+ )
+ if callback.message:
+ 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:
- """Открывает меню управления автокомментариями"""
- channels = settings.AUTO_COMMENT_CHANNELS_LIST
+ channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await!
+
+ await state.clear()
if not channels:
await message.answer(
- "❌ Каналы не настроены\n\n"
- "Добавьте ID каналов в .env файл:\n"
- "AUTO_COMMENT_CHANNELS=-1003876862007\n\n"
- "💡 Узнать ID канала: перешлите пост из канала боту @userinfobot",
+ "📢 УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ\n\n"
+ "🚫 Каналы не настроены\n\n"
+ "👆 ➕ Добавить канал",
+ reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка
parse_mode="HTML"
)
return
@@ -385,33 +464,14 @@ async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
await show_channel_menu(message, channels[0])
else:
await message.answer(
- "📢 УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ\n\n"
- "Выберите канал для настройки:",
+ "📢 Выберите канал:",
reply_markup=create_channels_menu(channels).as_markup(),
parse_mode="HTML"
)
-
async def show_channel_menu(message: Message, channel_id: int) -> None:
- """Показывает меню настроек для конкретного канала"""
config = await get_channel_config(channel_id)
- status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
-
- text = config.get("text") or ""
- photo_url = config.get("photo_url") or ""
- text_preview = (text[:100] + "...") if len(text) > 100 else text
- photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
-
- output = (
- f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n"
- f"📢 Канал: {channel_id}\n"
- f"🔘 Статус: {status_emoji}\n\n"
- f"📝 Текст:\n{text_preview or '(пусто)'}\n\n"
- f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n"
- f"🔗 URL: {config.get('button_url') or ''}\n\n"
- f"🖼 Фото:\n{photo_preview}\n\n"
- f"💡 Выберите действие:"
- )
+ output = _render_menu_text(channel_id, config)
await message.answer(
text=output,
@@ -419,77 +479,70 @@ async def show_channel_menu(message: Message, channel_id: int) -> None:
parse_mode="HTML"
)
-
@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])
+ await state.clear()
+
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"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n"
- f"📢 Канал: {channel_id}\n"
- f"🔘 Статус: {status_emoji}\n\n"
- f"📝 Текст:\n{text_preview or '(пусто)'}\n\n"
- f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n"
- f"🔗 URL: {config.get('button_url') or ''}\n\n"
- f"🖼 Фото:\n{photo_preview}\n\n"
- f"💡 Выберите действие:"
- )
-
- await callback.message.edit_text(
- text=output,
- reply_markup=create_main_menu(channel_id).as_markup(),
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(
+ text=output,
+ reply_markup=create_main_menu(channel_id).as_markup(),
+ parse_mode="HTML"
+ )
await callback.answer()
-
# ======================================================================
# EDIT TEXT
# ======================================================================
-@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"))
+@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"), IsAdmin())
async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None:
channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_text)
- await callback.message.edit_text(
- text=(
- "📝 РЕДАКТИРОВАНИЕ ТЕКСТА\n\n"
- "Отправьте новый текст комментария.\n\n"
- "💡 Поддерживается HTML\n\n"
- "Для отмены: /cancel"
- ),
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(
+ text=(
+ "📝 РЕДАКТИРОВАНИЕ ТЕКСТА\n\n"
+ "Отправьте новый текст комментария.\n\n"
+ "💡 Поддерживается HTML\n\n"
+ "Для отмены: /cancel"
+ ),
+ parse_mode="HTML"
+ )
await callback.answer()
-
-@router.message(CommentEditStates.waiting_text)
+@router.message(CommentEditStates.waiting_text, IsAdmin())
async def process_text_input(message: Message, state: FSMContext) -> None:
- if message.text == "/cancel":
+ if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
data = await state.get_data()
channel_id = data.get("channel_id")
+ if not channel_id:
+ await state.clear()
+ await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
+ return
- manager = get_manager()
- success = await manager.update_auto_comment_text(
- channel_id=channel_id,
- text=message.text or "",
- updated_by=message.from_user.id
- )
+ new_text = message.text or ""
+
+ try:
+ success = await _persist_settings_preserve_enabled(
+ channel_id=int(channel_id),
+ patch={"text": new_text},
+ 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()
@@ -500,35 +553,34 @@ async def process_text_input(message: Message, state: FSMContext) -> None:
)
return
- await message.answer(f"✅ Текст обновлён!", parse_mode="HTML")
- await show_channel_menu(message, channel_id)
-
+ await message.answer("✅ Текст обновлён!", parse_mode="HTML")
+ await show_channel_menu(message, int(channel_id))
# ======================================================================
# 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:
channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_button_text)
- await callback.message.edit_text(
- text=(
- "🔘 РЕДАКТИРОВАНИЕ КНОПКИ\n\n"
- "Шаг 1 из 2: Отправьте текст кнопки\n\n"
- "Для отмены: /cancel"
- ),
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(
+ text=(
+ "🔘 РЕДАКТИРОВАНИЕ КНОПКИ\n\n"
+ "Шаг 1 из 2: Отправьте текст кнопки\n\n"
+ "Для отмены: /cancel"
+ ),
+ parse_mode="HTML"
+ )
await callback.answer()
-
-@router.message(CommentEditStates.waiting_button_text)
+@router.message(CommentEditStates.waiting_button_text, IsAdmin())
async def process_button_text(message: Message, state: FSMContext) -> None:
- if message.text == "/cancel":
+ if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
@@ -538,22 +590,21 @@ async def process_button_text(message: Message, state: FSMContext) -> None:
await message.answer(
text=(
- f"✅ Текст кнопки: {message.text}\n\n"
+ f"✅ Текст кнопки: {(message.text or '').strip()}\n\n"
f"Шаг 2 из 2: Отправьте URL кнопки\n\n"
f"Для отмены: /cancel"
),
parse_mode="HTML"
)
-
-@router.message(CommentEditStates.waiting_button_url)
+@router.message(CommentEditStates.waiting_button_url, IsAdmin())
async def process_button_url(message: Message, state: FSMContext) -> None:
- if message.text == "/cancel":
+ if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
- url = message.text or ""
+ url = (message.text or "").strip()
if not url.startswith(("http://", "https://")):
await message.answer(
"❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://",
@@ -563,56 +614,62 @@ async def process_button_url(message: Message, state: FSMContext) -> None:
data = await state.get_data()
channel_id = data.get("channel_id")
- button_text = data.get("button_text") or ""
+ button_text = (data.get("button_text") or "").strip()
- manager = get_manager()
- success = await manager.update_auto_comment_button(
- channel_id=channel_id,
- button_text=button_text,
- button_url=url,
- updated_by=message.from_user.id
- )
+ if not channel_id:
+ await state.clear()
+ await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
+ return
+
+ 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
+ )
+ except Exception as e:
+ logger.error(f"update button failed: {e}", log_type="CHANNEL")
+ success = False
await state.clear()
if not success:
- await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
+ await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer("✅ Кнопка обновлена!", parse_mode="HTML")
- await show_channel_menu(message, channel_id)
-
+ await show_channel_menu(message, int(channel_id))
# ======================================================================
# 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:
channel_id = int(callback.data.split(":")[1])
await state.update_data(channel_id=channel_id)
await state.set_state(CommentEditStates.waiting_photo_url)
- await callback.message.edit_text(
- text=(
- "🖼 РЕДАКТИРОВАНИЕ ФОТО\n\n"
- "Отправьте прямую ссылку на изображение (http/https).\n\n"
- "Для отмены: /cancel"
- ),
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(
+ text=(
+ "🖼 РЕДАКТИРОВАНИЕ ФОТО\n\n"
+ "Отправьте прямую ссылку на изображение (http/https).\n\n"
+ "Для отмены: /cancel"
+ ),
+ parse_mode="HTML"
+ )
await callback.answer()
-
-@router.message(CommentEditStates.waiting_photo_url)
+@router.message(CommentEditStates.waiting_photo_url, IsAdmin())
async def process_photo_url(message: Message, state: FSMContext) -> None:
- if message.text == "/cancel":
+ if (message.text or "").strip() == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
- url = message.text or ""
+ url = (message.text or "").strip()
if not url.startswith(("http://", "https://")):
await message.answer(
"❌ Неверный формат URL\n\nURL должен начинаться с http:// или https://",
@@ -622,48 +679,54 @@ async def process_photo_url(message: Message, state: FSMContext) -> None:
data = await state.get_data()
channel_id = data.get("channel_id")
+ if not channel_id:
+ await state.clear()
+ await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
+ return
- manager = get_manager()
- success = await manager.update_auto_comment_photo(
- channel_id=channel_id,
- photo_url=url,
- updated_by=message.from_user.id
- )
+ try:
+ success = await _persist_settings_preserve_enabled(
+ channel_id=int(channel_id),
+ patch={"photo_url": url},
+ 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()
if not success:
- await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
+ await message.answer("❌ Ошибка сохранения\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer(hide_link(url) + "✅ Фото обновлено!", parse_mode="HTML")
- await show_channel_menu(message, channel_id)
-
+ await show_channel_menu(message, int(channel_id))
# ======================================================================
# 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:
channel_id = int(callback.data.split(":")[1])
config = await get_channel_config(channel_id)
full_text, keyboard = _build_comment_payload(config)
- await callback.message.answer(
- text=f"👁 ПРЕВЬЮ КОММЕНТАРИЯ\n\n{full_text}",
- reply_markup=keyboard.as_markup(),
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.answer(
+ text=f"👁 ПРЕВЬЮ КОММЕНТАРИЯ\n\n{full_text}",
+ reply_markup=keyboard.as_markup(),
+ parse_mode="HTML"
+ )
await callback.answer("✅ Превью отправлено")
-
# ======================================================================
# TOGGLE
# ======================================================================
-@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"))
+@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"), IsAdmin())
async def toggle_comment_callback(callback: CallbackQuery) -> None:
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)
- # Обновляем меню
config = await get_channel_config(channel_id)
- status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
- text = config.get("text") or ""
- photo_url = config.get("photo_url") or ""
- text_preview = (text[:100] + "...") if len(text) > 100 else text
- photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
-
- output = (
- f"⚙️ НАСТРОЙКА АВТОКОММЕНТАРИЕВ\n\n"
- f"📢 Канал: {channel_id}\n"
- f"🔘 Статус: {status_emoji}\n\n"
- f"📝 Текст:\n{text_preview or '(пусто)'}\n\n"
- f"🔘 Кнопка: {config.get('button_text') or '(нет)'}\n"
- f"🔗 URL: {config.get('button_url') or ''}\n\n"
- f"🖼 Фото:\n{photo_preview}\n\n"
- f"💡 Выберите действие:"
- )
-
- await callback.message.edit_text(
- text=output,
- reply_markup=create_main_menu(channel_id).as_markup(),
- parse_mode="HTML"
- )
+ output = _render_menu_text(channel_id, config)
+ if callback.message:
+ await callback.message.edit_text(
+ text=output,
+ reply_markup=create_main_menu(channel_id).as_markup(),
+ parse_mode="HTML"
+ )
# ======================================================================
# DELETE SETTINGS
# ======================================================================
-@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"))
+@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"), IsAdmin())
async def delete_comment_callback(callback: CallbackQuery) -> None:
channel_id = int(callback.data.split(":")[1])
@@ -737,34 +784,16 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
return
await callback.answer("🗑 Настройки удалены", show_alert=True)
- await callback.message.edit_text(
- text=(
- "🗑 НАСТРОЙКИ УДАЛЕНЫ\n\n"
- f"Автокомментарии для канала {channel_id} удалены.\n\n"
- "Будут использоваться настройки по умолчанию из .env\n\n"
- "Для настройки: /redactcomment"
- ),
- parse_mode="HTML"
- )
+
+ if callback.message:
+ await callback.message.edit_text(
+ text=(
+ "🗑 НАСТРОЙКИ УДАЛЕНЫ\n\n"
+ f"Автокомментарии для канала {channel_id} удалены.\n\n"
+ "Будут использоваться настройки по умолчанию из .env\n\n"
+ "Для настройки: /redactcomment"
+ ),
+ parse_mode="HTML"
+ )
-# ======================================================================
-# CLOSE / CANCEL
-# ======================================================================
-
-@router.callback_query(F.data == "menu:close")
-async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
- await state.clear()
- await callback.message.delete()
- await callback.answer("❌ Меню закрыто")
-
-
-@router.message(Command("cancel"))
-async def cancel_handler(message: Message, state: FSMContext) -> None:
- current_state = await state.get_state()
- if current_state is None:
- await message.answer("❌ Нечего отменять")
- return
-
- await state.clear()
- await message.answer("✅ Действие отменено")
diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py
index d3fab41..a6cbd95 100644
--- a/bot/handlers/commands/users/__init__.py
+++ b/bot/handlers/commands/users/__init__.py
@@ -11,6 +11,8 @@ from .admins import router as admin_router
from .notifications import router as notifications_router
from .id import router as id_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",)
@@ -19,6 +21,7 @@ router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
+cancel_router,
notifications_router,
report_router,
admin_router,
@@ -30,4 +33,5 @@ conflict_router,
stats_router,
id_router,
emoji_router,
+setting_router,
)
diff --git a/bot/handlers/commands/users/admins.py b/bot/handlers/commands/users/admins.py
index 46ffe95..3c2f320 100644
--- a/bot/handlers/commands/users/admins.py
+++ b/bot/handlers/commands/users/admins.py
@@ -6,248 +6,197 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from bot import bot # ← ДОБАВЬ ЭТОТ ИМПОРТ
from bot.filters.admin import IsSuperAdmin
from configs import settings, COMMANDS
from database import get_manager
from middleware.loggers import logger
-from bot.utils.decorators import log_action
+from bot.utils import log_action, tg_emoji
__all__ = ("router",)
router: Router = Router(name="admin_management_router")
-# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
-
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)
-
if len(parts) < 2:
- return False, f"❌ Использование: /{command} "
+ return False, f'{tg_emoji("4961187972822074653")} Использование: /{command} '
user_id_str = parts[1].strip()
-
- # Валидация ID
try:
user_id = int(user_id_str)
-
if user_id <= 0:
- return False, "❌ ID должен быть положительным числом"
-
- if user_id > 9999999999: # Максимальный Telegram ID
- return False, "❌ Некорректный ID пользователя"
-
+ return False, f'{tg_emoji("4961187972822074653")} ID должен быть положительным числом'
+ if user_id > 9999999999:
+ return False, f'{tg_emoji("4961187972822074653")} Некорректный ID пользователя'
return True, user_id
-
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:
- """Форматирует информацию об админе"""
if username:
- return f"{user_id} (@{username})"
- return f"{user_id}"
+ return f'{user_id} (@{username})'
+ return f'{user_id}'
def get_refresh_admins_kb():
- """Клавиатура для обновления списка админов"""
ikb = InlineKeyboardBuilder()
- ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh")
- ikb.button(text="➕ Добавить", callback_data="admin:help_add")
+ ikb.button(text='🔄 Обновить', callback_data='listadmins:refresh')
+ ikb.button(text='➕ Добавить', callback_data='admin:help_add')
ikb.adjust(2)
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())
-@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:
- """
- Добавляет нового администратора бота.
-
- Доступно только владельцам бота (OWNER_ID).
-
- Использование: /addadmin
- Пример: /addadmin 123456789
- """
- success, result = parse_user_id(message.text, "addadmin")
-
+ success, result = parse_user_id(message.text, 'addadmin')
if not success:
- await message.answer(result, parse_mode="HTML")
+ await message.answer(result, parse_mode='HTML')
return
user_id = result
- # Проверка: нельзя добавить самого себя
if user_id == message.from_user.id:
await message.answer(
- "⚠️ Вы уже владелец бота\n\n"
- "Вам не нужно добавлять себя в администраторы",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Вы уже владелец бота\n\n'
+ 'Вам не нужно добавлять себя в администраторы',
+ parse_mode='HTML'
)
return
- # Проверка: нельзя добавить другого владельца
if user_id in settings.OWNER_ID:
await message.answer(
- "⚠️ Этот пользователь уже владелец бота\n\n"
- "Владельцы имеют полные права автоматически",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Этот пользователь уже владелец бота\n\n'
+ 'Владельцы имеют полные права автоматически',
+ parse_mode='HTML'
)
return
manager = get_manager()
-
try:
- # Проверяем, уже админ ли
is_already_admin = await manager.is_admin(user_id)
-
if is_already_admin:
+ display_name = await get_user_display_name(user_id)
await message.answer(
- f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Пользователь {display_name} уже является администратором',
+ parse_mode='HTML'
)
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:
+ display_name = await get_user_display_name(user_id)
text = (
- f"✅ Администратор добавлен\n\n"
- f"👤 ID: {format_admin_info(user_id)}\n"
- f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
- f"📋 Права администратора:\n"
- f"├─ Управление банвордами\n"
- f"├─ Просмотр статистики\n"
- f"├─ Активация режимов модерации\n"
- f"└─ Все команды бота\n\n"
- f"⚠️ Не может управлять другими админами\n"
- f"Список админов: /listadmins"
- )
-
- logger.info(
- f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
- log_type="ADMIN_MGMT"
+ f'{tg_emoji("4963010134172239128")} Администратор добавлен\n\n'
+ f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
+ f'{tg_emoji("4963343509533754468")} Имя: {display_name}\n'
+ f'{tg_emoji("4963343509533754468")} Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
+ f'{tg_emoji("4961106084975608869")} Права администратора:\n'
+ f'├─ Управление банвордами\n'
+ f'├─ Просмотр статистики\n'
+ f'├─ Активация режимов модерации\n'
+ f'└─ Все команды бота\n\n'
+ f'{tg_emoji("4963024861615096794")} Не может управлять другими админами\n'
+ f'Список админов: /listadmins'
)
+ logger.info(f'Администратор добавлен: {user_id} (добавил: {message.from_user.id})', log_type='ADMIN_MGMT')
else:
- text = "❌ Ошибка добавления администратора\n\nПопробуйте позже"
+ text = f'{tg_emoji("4961187972822074653")} Ошибка добавления администратора\n\nПопробуйте позже'
- await message.answer(text, parse_mode="HTML")
+ await message.answer(text, parse_mode='HTML')
except Exception as e:
- logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT")
- await message.answer("❌ Ошибка добавления\n\nПопробуйте позже", parse_mode="HTML")
+ logger.error(f'Ошибка добавления администратора: {e}', log_type='ADMIN_MGMT')
+ await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка добавления\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())
-@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:
- """
- Удаляет администратора бота.
-
- Доступно только владельцам бота (OWNER_ID).
-
- Использование: /remadmin
- Пример: /remadmin 123456789
- """
- success, result = parse_user_id(message.text, "remadmin")
-
+ success, result = parse_user_id(message.text, 'remadmin')
if not success:
- await message.answer(result, parse_mode="HTML")
+ await message.answer(result, parse_mode='HTML')
return
user_id = result
- # Проверка: нельзя удалить владельца
if user_id in settings.OWNER_ID:
await message.answer(
- "⚠️ Нельзя удалить владельца\n\n"
- "Владельцы имеют права постоянно",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Нельзя удалить владельца\n\n'
+ 'Владельцы имеют права постоянно',
+ parse_mode='HTML'
)
return
- # Проверка: нельзя удалить самого себя (если вы владелец)
if user_id == message.from_user.id:
await message.answer(
- "⚠️ Нельзя удалить самого себя",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Нельзя удалить самого себя',
+ parse_mode='HTML'
)
return
manager = get_manager()
-
try:
- # Проверяем, является ли администратором
is_admin = await manager.is_admin(user_id)
-
if not is_admin:
+ display_name = await get_user_display_name(user_id)
await message.answer(
- f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором",
- parse_mode="HTML"
+ f'{tg_emoji("4963024861615096794")} Пользователь {display_name} не является администратором',
+ parse_mode='HTML'
)
return
- # Удаляем администратора
removed = await manager.remove_admin(user_id=user_id)
-
if removed:
+ display_name = await get_user_display_name(user_id)
text = (
- f"🗑 Администратор удалён\n\n"
- f"👤 ID: {format_admin_info(user_id)}\n"
- f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
- f"⚠️ Пользователь больше не имеет доступа к командам бота"
- )
-
- logger.info(
- f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
- log_type="ADMIN_MGMT"
+ f'🗑 Администратор удалён\n\n'
+ f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
+ f'{tg_emoji("4961064956368782417")} Имя: {display_name}\n'
+ f'{tg_emoji("4963343509533754468")} Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
+ f'{tg_emoji("4963024861615096794")} Пользователь больше не имеет доступа к командам бота'
)
+ logger.info(f'Администратор удалён: {user_id} (удалил: {message.from_user.id})', log_type='ADMIN_MGMT')
else:
- text = "❌ Ошибка удаления администратора\n\nПопробуйте позже"
+ text = f'{tg_emoji("4961187972822074653")} Ошибка удаления администратора\n\nПопробуйте позже'
- await message.answer(text, parse_mode="HTML")
+ await message.answer(text, parse_mode='HTML')
except Exception as e:
- logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT")
- await message.answer("❌ Ошибка удаления\n\nПопробуйте позже", parse_mode="HTML")
+ logger.error(f'Ошибка удаления администратора: {e}', log_type='ADMIN_MGMT')
+ await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка удаления\n\nПопробуйте позже', parse_mode='HTML')
# ================= СПИСОК АДМИНИСТРАТОРОВ =================
-@router.callback_query(F.data == "listadmins:refresh")
-@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True),
+@router.callback_query(F.data == 'listadmins:refresh')
+@router.message(Command(*COMMANDS.get('listadmins', ['listadmins']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
-@log_action(action_name="LIST_ADMINS")
+@log_action(action_name='LIST_ADMINS')
async def list_admins_cmd(update: Message | CallbackQuery) -> None:
- """
- Показывает список всех администраторов бота.
-
- Доступно только владельцам бота (OWNER_ID).
-
- Использование: /listadmins
- """
- # Определяем тип update
if isinstance(update, CallbackQuery):
message = update.message
is_callback = True
@@ -256,179 +205,143 @@ async def list_admins_cmd(update: Message | CallbackQuery) -> None:
is_callback = False
manager = get_manager()
-
try:
- # Получаем всех админов из БД
db_admins = await manager.repo.get_admins()
- # Получаем статистику
- stats = await manager.get_stats()
+ output = f'{tg_emoji("4960891456869893259")} СПИСОК АДМИНИСТРАТОРОВ\n\n'
- # === ФОРМИРУЕМ ВЫВОД ===
-
- output = "👥 СПИСОК АДМИНИСТРАТОРОВ\n\n"
-
- # Владельцы (OWNER_ID)
- output += "👑 Владельцы бота (полные права):\n"
+ # ВЛАДЕЛЬЦЫ
+ output += f'{tg_emoji("4963343509533754468")} Владельцы бота (полные права):\n'
for owner_id in settings.OWNER_ID:
- output += f'├─ {owner_id}\n'
- output += "\n"
+ display_name = await get_user_display_name(owner_id)
+ output += f'├─ {display_name}\n'
+ output += '\n'
- # Администраторы из БД
+ # АДМИНИСТРАТОРЫ
if db_admins:
- output += f"⚙️ Администраторы ({len(db_admins)}):\n"
-
+ output += f'{tg_emoji("4961064956368782417")} Администраторы ({len(db_admins)}):\n'
for admin_id in sorted(db_admins):
- output += f'├─ {admin_id}\n'
-
- output += "\n"
- output += "📋 Права администраторов:\n"
- output += "├─ Управление банвордами\n"
- output += "├─ Просмотр статистики\n"
- output += "├─ Активация режимов модерации\n"
- output += "└─ Все команды бота (кроме управления админами)\n\n"
+ display_name = await get_user_display_name(admin_id)
+ output += f'├─ {display_name}\n'
+ output += '\n'
+ output += f'{tg_emoji("4961106084975608869")} Права администраторов:\n'
+ output += '├─ Управление банвордами\n'
+ output += '├─ Просмотр статистики\n'
+ output += '├─ Активация режимов модерации\n'
+ output += '└─ Все команды бота\n\n'
else:
- output += "⚙️ Администраторы:\n"
- output += "└─ Нет дополнительных администраторов\n\n"
+ output += f'{tg_emoji("4961064956368782417")} Администраторы:\n'
+ output += '└─ Нет дополнительных администраторов\n\n'
- # Общая статистика
total_admins = len(settings.OWNER_ID) + len(db_admins)
- output += f"📊 Итого: {total_admins} администратор(ов)\n\n"
+ output += f'{tg_emoji("4961061266991875258")} Итого: {total_admins} администратор(ов)\n\n'
- # Команды управления
- output += "🔧 Управление:\n"
- output += "• /addadmin ID — добавить админа\n"
- output += "• /remadmin ID — удалить админа\n\n"
+ output += f'{tg_emoji("4961027057577362562")} Управление:\n'
+ output += '• /adminhelp — помощь по командам админов\n'
+ output += '• /addadmin ID — добавить админа\n'
+ output += '• /remadmin ID — удалить админа\n\n'
+ output += f'{tg_emoji("4961186405159011104")} Только владельцы могут управлять администраторами'
- output += "💡 Только владельцы могут управлять администраторами"
-
- # Клавиатура
keyboard = get_refresh_admins_kb()
- # Отправка
if is_callback:
- await message.edit_text(
- text=output,
- parse_mode="HTML",
- reply_markup=keyboard
- )
- await update.answer("✅ Список обновлён")
+ await message.edit_text(text=output, parse_mode='HTML', reply_markup=keyboard)
+ await update.answer(f'{tg_emoji("4963010134172239128")} Список обновлён')
else:
- await message.answer(
- text=output,
- parse_mode="HTML",
- reply_markup=keyboard
- )
+ await message.answer(text=output, parse_mode='HTML', reply_markup=keyboard)
except Exception as e:
- logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT")
-
- error_text = "❌ Ошибка загрузки списка\n\nПопробуйте позже"
-
+ logger.error(f'Ошибка получения списка администраторов: {e}', log_type='ADMIN_MGMT')
+ error_text = f'{tg_emoji("4961187972822074653")} Ошибка загрузки списка\n\nПопробуйте позже'
if is_callback:
- await update.answer("❌ Ошибка загрузки", show_alert=True)
+ await update.answer(f'{tg_emoji("4961187972822074653")} Ошибка загрузки', show_alert=True)
else:
- await message.answer(error_text, parse_mode="HTML")
+ await message.answer(error_text, parse_mode='HTML')
# ================= ВСПОМОГАТЕЛЬНЫЕ 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:
- """Показывает помощь по добавлению админа"""
text = (
- "➕ Как добавить администратора?\n\n"
- "1️⃣ Узнайте Telegram ID пользователя\n"
- " • Используйте бота @userinfobot\n"
- " • Или попросите пользователя написать /start\n\n"
- "2️⃣ Выполните команду:\n"
- " /addadmin ID\n\n"
- "Пример:\n"
- "/addadmin 123456789"
+ f'{tg_emoji("4963469772982322370")} Как добавить администратора?\n\n'
+ f'{tg_emoji("4960889107522782272")} Узнайте Telegram ID пользователя\n'
+ ' • Используйте команду /id или бота @userinfobot\n'
+ f'{tg_emoji("4960889107522782272")} Выполните команду:\n'
+ ' /addadmin ID\n\n'
+ 'Пример:\n'
+ '/addadmin 123456789'
)
-
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())
async def admin_help_cmd(message: Message) -> None:
- """
- Показывает подробную справку по управлению администраторами.
-
- Использование: /adminhelp
- """
text = (
- "👥 УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ\n\n"
- "🔐 Уровни доступа:\n\n"
- "👑 Владельцы (OWNER_ID):\n"
- "├─ Все права администратора\n"
- "├─ Управление другими админами\n"
- "└─ Указываются в конфигурации\n\n"
- "⚙️ Администраторы:\n"
- "├─ Управление банвордами\n"
- "├─ Просмотр статистики\n"
- "├─ Активация режимов модерации\n"
- "└─ НЕ могут управлять админами\n\n"
- "📝 Команды:\n"
- "• /listadmins — список всех админов\n"
- "• /addadmin ID — добавить админа\n"
- "• /remadmin ID — удалить админа\n\n"
- "💡 Как узнать ID пользователя?\n"
- "• Используйте бота @userinfobot\n"
- "• Попросите пользователя написать боту\n"
- "• ID отображается в логах бота\n\n"
- "⚠️ Важно:\n"
- "├─ Нельзя удалить владельца\n"
- "├─ Нельзя удалить самого себя\n"
- "└─ Все действия логируются"
+ f'{tg_emoji("4960891456869893259")} УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ\n\n'
+ f'{tg_emoji("4963401727815451692")} Уровни доступа:\n\n'
+ f'{tg_emoji("4963343509533754468")} Владельцы (OWNER_ID):\n'
+ '├─ Все права администратора\n'
+ '├─ Управление другими админами\n'
+ '└─ Указываются в конфигурации\n\n'
+ f'{tg_emoji("4961064956368782417")} Администраторы:\n'
+ '├─ Управление банвордами\n'
+ '├─ Просмотр статистики\n'
+ '├─ Активация режимов модерации\n'
+ '└─ Не могут управлять админами\n\n'
+ f'{tg_emoji("4963241130398319816")} Команды:\n'
+ '• /adminhelp — помощь по командам админов\n'
+ '• /listadmins — список всех админов\n'
+ '• /addadmin ID — добавить админа\n'
+ '• /remadmin ID — удалить админа\n\n'
+ f'{tg_emoji("4961186405159011104")} Как узнать ID пользователя?\n'
+ '• Используйте команду /id или бота @userinfobot\n'
+ '• Или попросите пользователя написать боту\n'
+ '• ID отображается в логах бота\n\n'
+ f'{tg_emoji("4963024861615096794")} Важно:\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())
-@log_action(action_name="CHECK_ADMIN")
+@log_action(action_name='CHECK_ADMIN')
async def check_admin_cmd(message: Message) -> None:
- """
- Проверяет, является ли пользователь администратором.
-
- Использование: /checkadmin
- """
- success, result = parse_user_id(message.text, "checkadmin")
-
+ success, result = parse_user_id(message.text, 'checkadmin')
if not success:
- await message.answer(result, parse_mode="HTML")
+ await message.answer(result, parse_mode='HTML')
return
user_id = result
manager = get_manager()
try:
- # Проверяем статус
is_owner = user_id in settings.OWNER_ID
is_db_admin = await manager.is_admin(user_id)
- text = f"🔍 Проверка пользователя\n\n"
- text += f"👤 ID: {user_id}\n\n"
+ text = f'{tg_emoji("4961092195051373778")} Проверка пользователя\n\n'
+ text += f'{tg_emoji("4961064956368782417")} ID: {user_id}\n\n'
if is_owner:
- text += "👑 Статус: Владелец бота\n"
- text += "✅ Полные права администратора\n"
- text += "✅ Может управлять админами"
+ text += f'{tg_emoji("4963343509533754468")} Статус: Владелец бота\n'
+ text += f'{tg_emoji("4963010134172239128")} Полные права администратора\n'
+ text += f'{tg_emoji("4963010134172239128")} Может управлять админами'
elif is_db_admin:
- text += "⚙️ Статус: Администратор\n"
- text += "✅ Доступ к командам бота\n"
- text += "❌ Не может управлять админами"
+ text += f'{tg_emoji("4961064956368782417")} Статус: Администратор\n'
+ text += f'{tg_emoji("4963010134172239128")} Доступ к командам бота\n'
+ text += f'{tg_emoji("4961187972822074653")} Не может управлять админами'
else:
- text += "👤 Статус: Обычный пользователь\n"
- text += "❌ Нет прав администратора\n\n"
- text += f"Добавить в админы: /addadmin {user_id}"
+ text += f'{tg_emoji("4961064956368782417")} Статус: Обычный пользователь\n'
+ text += f'{tg_emoji("4961187972822074653")} Нет прав администратора\n\n'
+ text += f'Добавить в админы: /addadmin {user_id}'
- await message.answer(text, parse_mode="HTML")
+ await message.answer(text, parse_mode='HTML')
except Exception as e:
- logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT")
- await message.answer("❌ Ошибка проверки", parse_mode="HTML")
+ logger.error(f'Ошибка проверки администратора: {e}', log_type='ADMIN_MGMT')
+ await message.answer(f'{tg_emoji("4961187972822074653")} Ошибка проверки', parse_mode='HTML')
diff --git a/bot/handlers/commands/users/bot_settings.py b/bot/handlers/commands/users/bot_settings.py
new file mode 100644
index 0000000..23edb71
--- /dev/null
+++ b/bot/handlers/commands/users/bot_settings.py
@@ -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"✅ {chat_id}"
+
+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 = (
+ "⚙️ НАСТРОЙКИ БОТА\n\n"
+ "📢 Админ-чат: " + _format_chat_id(current.get('admin_chat_id')) + "\n"
+ "🧵 Топик админ: " + _format_chat_id(current.get('admin_thread_id')) + "\n\n"
+ "📊 Чат репортов: " + _format_chat_id(current.get('report_chat_id')) + "\n"
+ "🧵 Топик репортов: " + _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(
+ "📢 АДМИН-ЧАТ\n\n"
+ "Отправьте ID чата для уведомлений:\n"
+ "Пример: -1003764219200\n\n"
+ "Для отключения: null\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(
+ "🧵 ТОПИК АДМИН-ЧАТА\n\n"
+ "Отправьте ID топика:\n"
+ "Пример: 1\n\n"
+ "Для отключения: null\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(
+ "📊 ЧАТ РЕПОРТОВ\n\n"
+ "Отправьте ID чата для репортов:\n"
+ "Пример: -1003764219200\n\n"
+ "Для отключения: null\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(
+ "🧵 ТОПИК РЕПОРТОВ\n\n"
+ "Отправьте ID топика:\n"
+ "Пример: 1\n\n"
+ "Для отключения: null\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("❌ Неверный формат. Пример: -1003764219200", 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("❌ Неверный формат. Пример: 1", 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("❌ Неверный формат. Пример: -1003764219200", 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("❌ Неверный формат. Пример: 1", 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("✅ Настройки отменены")
diff --git a/bot/handlers/commands/users/cancel.py b/bot/handlers/commands/users/cancel.py
new file mode 100644
index 0000000..bd9d059
--- /dev/null
+++ b/bot/handlers/commands/users/cancel.py
@@ -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("✅ Действие отменено")
diff --git a/bot/handlers/commands/users/emoji.py b/bot/handlers/commands/users/emoji.py
index 807a908..4744966 100644
--- a/bot/handlers/commands/users/emoji.py
+++ b/bot/handlers/commands/users/emoji.py
@@ -14,23 +14,28 @@ __all__ = ("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]:
"""
- Извлекает все кастомные эмодзи из сообщения.
-
- Args:
- message: Сообщение для анализа
+ Извлекает все кастомные эмодзи из сообщения (текст + подпись).
Returns:
- Список словарей с информацией об эмодзи
+ Список словарей: {"char": str, "id": str, "offset": int}
"""
- if not message.entities and not message.caption_entities:
- return []
-
- # Определяем текст и entities
text = message.text or message.caption
entities = message.entities or message.caption_entities
@@ -38,44 +43,76 @@ def extract_custom_emojis(message: Message) -> list[dict]:
return []
custom_emojis = []
-
for entity in entities:
if entity.type == "custom_emoji":
- # Извлекаем символ эмодзи
- emoji_char = text[entity.offset:entity.offset + entity.length]
-
+ emoji_char = _utf16_slice(text, entity.offset, entity.length)
custom_emojis.append({
"char": emoji_char,
"id": entity.custom_emoji_id,
- "offset": entity.offset
+ "offset": entity.offset,
})
return custom_emojis
def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
- """
- Форматирует эмодзи в HTML-тег.
-
- Args:
- emoji_char: Символ эмодзи (fallback)
- emoji_id: ID кастомного эмодзи
-
- Returns:
- HTML-строка
- """
return f'{emoji_char}'
def escape_html(text: str) -> str:
- """Экранирует HTML символы"""
return (
text.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
+ .replace("<", "<")
+ .replace(">", ">")
)
+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"{idx}. Эмодзи: {emoji_char}\n"
+ f"📋 ID: {emoji_id}\n\n"
+ f"📝 HTML-код:\n"
+ f"{html_escaped}\n\n"
+ f"🎨 Превью: {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 =================
@router.message(
@@ -83,14 +120,6 @@ def escape_html(text: str) -> str:
IsAdmin()
)
async def emoji_extractor_cmd(message: Message) -> None:
- """
- Извлекает кастомные эмодзи из сообщения.
-
- Доступно только администраторам.
-
- Использование: /emoji (в ответ на сообщение)
- """
- # Проверяем, что команда в ответ на сообщение
if not message.reply_to_message:
await message.answer(
"❌ Используйте команду в ответ на сообщение\n\n"
@@ -98,66 +127,57 @@ async def emoji_extractor_cmd(message: Message) -> None:
"1. Ответьте на сообщение с премиум эмодзи\n"
"2. Напишите /emoji\n\n"
"💡 Бот извлечёт все кастомные эмодзи и покажет HTML-код",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
replied_message = message.reply_to_message
-
- # Извлекаем кастомные эмодзи
custom_emojis = extract_custom_emojis(replied_message)
if not custom_emojis:
- # Нет кастомных эмодзи
await message.answer(
"⚠️ Кастомные эмодзи не найдены\n\n"
"В этом сообщении нет премиум эмодзи.\n\n"
"💡 Попробуйте ответить на сообщение с анимированными эмодзи",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
- # === ФОРМИРУЕМ ОТВЕТ ===
+ total = len(custom_emojis)
+ pages = build_pages(custom_emojis)
+ total_pages = len(pages)
- output = f"✨ НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}\n\n"
-
- for idx, emoji_data in enumerate(custom_emojis, 1):
- emoji_char = emoji_data["char"]
- emoji_id = emoji_data["id"]
-
- output += f"{idx}. Эмодзи: {emoji_char}\n"
- output += f"📋 ID: {emoji_id}\n\n"
-
- # HTML-код (экранированный для отображения)
- html_code = format_emoji_html(emoji_char, emoji_id)
- html_escaped = escape_html(html_code)
-
- output += f"📝 HTML-код:\n"
- output += f"{html_escaped}\n\n"
-
- # Пример использования
- output += f"🎨 Превью: {html_code}\n"
-
- if idx < len(custom_emojis):
- output += "\n" + "─" * 30 + "\n\n"
-
- output += "💡 Скопируйте HTML-код и используйте в своих сообщениях"
-
- # Создаём клавиатуру
ikb = InlineKeyboardBuilder()
ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
- # Отправляем
try:
- await message.answer(
- text=output,
- parse_mode="HTML",
- reply_markup=ikb.as_markup()
- )
+ for page_num, page_content in enumerate(pages, 1):
+ # Заголовок только на первой странице
+ if page_num == 1:
+ header = f"✨ НАЙДЕНО ЭМОДЗИ: {total}\n\n"
+ else:
+ header = f"✨ ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})\n\n"
+
+ # Подвал только на последней странице
+ footer = (
+ "\n\n💡 Скопируйте HTML-код и используйте в своих сообщениях"
+ if page_num == total_pages
+ else ""
+ )
+
+ # Кнопка закрытия только на последней странице
+ markup = ikb.as_markup() if page_num == total_pages else None
+
+ await message.answer(
+ text=header + page_content + footer,
+ parse_mode="HTML",
+ reply_markup=markup,
+ )
logger.info(
- f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}",
- log_type="EMOJI_EXTRACT"
+ f"Извлечено {total} кастомных эмодзи ({total_pages} стр.) "
+ f"админом {message.from_user.id}",
+ log_type="EMOJI_EXTRACT",
)
except Exception as e:
@@ -165,7 +185,7 @@ async def emoji_extractor_cmd(message: Message) -> None:
await message.answer(
"❌ Ошибка извлечения эмодзи\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())
async def emoji_close_callback(callback) -> None:
- """Закрывает сообщение с эмодзи"""
try:
await callback.message.delete()
await callback.answer("✅ Закрыто")
@@ -189,9 +208,6 @@ async def emoji_close_callback(callback) -> None:
IsAdmin()
)
async def emoji_help_cmd(message: Message) -> None:
- """
- Справка по работе с кастомными эмодзи.
- """
text = (
"🎨 РАБОТА С КАСТОМНЫМИ ЭМОДЗИ\n\n"
"📝 Команда /emoji\n"
@@ -201,15 +217,11 @@ async def emoji_help_cmd(message: Message) -> None:
"2️⃣ Напишите /emoji\n"
"3️⃣ Скопируйте HTML-код\n\n"
"💻 Формат HTML-кода:\n"
- "<tg-emoji emoji-id=\"ID\">fallback</tg-emoji>\n\n"
- "📌 Пример использования в коде:\n"
- "text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\n"
- "await message.answer(text, parse_mode=\"HTML\")\n\n"
+ '<tg-emoji emoji-id="ID">fallback</tg-emoji>\n\n'
"⚠️ Важно:\n"
- "├─ Используйте parse_mode=\"HTML\"\n"
+ '├─ Используйте parse_mode="HTML"\n'
"├─ Пользователи без Premium видят fallback\n"
"└─ Работает только с кастомными эмодзи\n\n"
"💡 Попробуйте отправить эмодзи и ответить командой /emoji"
)
-
await message.answer(text, parse_mode="HTML")
diff --git a/bot/handlers/commands/users/id.py b/bot/handlers/commands/users/id.py
index a23fe07..caa3a1a 100644
--- a/bot/handlers/commands/users/id.py
+++ b/bot/handlers/commands/users/id.py
@@ -9,9 +9,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from configs import settings, COMMANDS
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():
"""Создаёт клавиатуру с кнопкой закрытия"""
ikb = InlineKeyboardBuilder()
- ikb.button(text="✖️ Закрыть", callback_data="id_close")
+ ikb.button(text='✖️ Закрыть', callback_data='id_close')
return ikb.as_markup()
# ================= КОМАНДА /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:
"""
Показывает информацию о вашем Telegram аккаунте.
@@ -37,12 +37,12 @@ async def id_cmd(message: Message) -> None:
user = message.from_user
if not user:
- await message.answer("❌ Не удалось получить информацию о пользователе")
+ await message.answer('❌ Не удалось получить информацию о пользователе')
return
# === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
- output = "👤 ИНФОРМАЦИЯ О ВАС\n\n"
+ output = '💠 ИНФОРМАЦИЯ О ВАС\n\n'
# Имя
full_name_parts = []
@@ -51,28 +51,28 @@ async def id_cmd(message: Message) -> None:
if user.last_name:
full_name_parts.append(user.last_name)
- full_name = " ".join(full_name_parts) if full_name_parts else "Не указано"
- output += f"📝 Имя: {full_name}\n"
+ full_name = ' '.join(full_name_parts) if full_name_parts else 'Не указано'
+ output += f'💠 Имя: {full_name}\n'
# Username
if user.username:
- output += f"🔗 Username: @{user.username}\n"
+ output += f'💠 Username: @{user.username}\n'
else:
- output += f"🔗 Username: не установлен\n"
+ output += '💠 Username: не установлен\n'
# ID
- output += f"🆔 ID: {user.id}\n\n"
+ output += f'💠 ID: {user.id}\n\n'
# Тип аккаунта
if user.is_bot:
- output += f"🤖 Тип: Бот\n"
- elif user.is_premium:
- output += f"⭐️ Тип: Premium пользователь\n"
+ output += '🤖 Тип: Бот\n'
+ elif getattr(user, 'is_premium', False):
+ output += '💠 Тип: Premium пользователь\n'
else:
- output += f"👥 Тип: Обычный пользователь\n"
+ output += '👥 Тип: Обычный пользователь\n'
# Дополнительная информация
- output += "\n📊 Дополнительно:\n"
+ output += '\n💠 Дополнительно:\n'
# Язык
if user.language_code:
@@ -86,36 +86,33 @@ async def id_cmd(message: Message) -> None:
'it': '🇮🇹 Italiano',
'pt': '🇵🇹 Português',
}
- language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}")
- output += f"├─ Язык: {language}\n"
+ language = language_names.get(user.language_code, f'🌐 {user.language_code.upper()}')
+ output += f'├─ Язык: {language}\n'
# Информация о чате
- if message.chat.type == "private":
- output += f"├─ Чат: 💬 Личные сообщения\n"
+ if message.chat.type == 'private':
+ output += '├─ Чат: 💬 Личные сообщения\n'
else:
- chat_title = message.chat.title or "Без названия"
+ chat_title = message.chat.title or 'Без названия'
chat_types = {
- "group": "👥 Группа",
- "supergroup": "👥 Супергруппа",
- "channel": "📢 Канал"
+ 'group': '👥 Группа',
+ 'supergroup': '👥 Супергруппа',
+ 'channel': '📢 Канал'
}
- chat_type = chat_types.get(message.chat.type, "💬 Чат")
- output += f"├─ Чат: {chat_type}\n"
- output += f"├─ Название: {chat_title}\n"
- output += f"├─ Chat ID: {message.chat.id}\n"
+ chat_type = chat_types.get(message.chat.type, '💬 Чат')
+ output += f'├─ Чат: {chat_type}\n'
+ output += f'├─ Название: {chat_title}\n'
+ output += f'├─ Chat ID: {message.chat.id}\n'
# Получаем количество участников (только для групп)
try:
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:
- logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
+ logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
# Message ID
- output += f"└─ Message ID: {message.message_id}\n\n"
-
- # Подсказка
- output += "💡 Эту информацию видите только вы"
+ output += f'└─ Message ID: {message.message_id}\n\n'
# Клавиатура
keyboard = get_close_keyboard()
@@ -124,33 +121,33 @@ async def id_cmd(message: Message) -> None:
try:
await message.answer(
text=output,
- parse_mode="HTML",
+ parse_mode='HTML',
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:
- logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR")
- await message.answer("❌ Произошла ошибка при получении информации")
+ logger.error(f'Ошибка отправки информации о пользователе: {e}', log_type='ERROR')
+ 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:
"""Закрывает (удаляет) сообщение с информацией"""
try:
await callback.message.delete()
- await callback.answer("✅ Закрыто")
+ await callback.answer('✅ Закрыто')
except Exception as e:
- logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR")
- await callback.answer("❌ Не удалось удалить сообщение", show_alert=True)
+ logger.error(f'Ошибка удаления сообщения ID: {e}', log_type='ERROR')
+ await callback.answer('❌ Не удалось удалить сообщение', show_alert=True)
# ================= КОМАНДА /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:
"""
Быстрый просмотр вашего ID.
@@ -160,21 +157,21 @@ async def myid_cmd(message: Message) -> None:
user = message.from_user
if not user:
- await message.answer("❌ Не удалось получить ID")
+ await message.answer('❌ Не удалось получить ID')
return
# Короткий ответ
- text = f"🆔 Ваш ID: {user.id}"
+ text = f'💠 Ваш ID: {user.id}'
if user.username:
- text += f"\n🔗 Username: @{user.username}"
+ text += f'\n💠 Username: @{user.username}'
- await message.answer(text, parse_mode="HTML")
+ await message.answer(text, parse_mode='HTML')
# ================= КОМАНДА /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:
"""
Показывает ID текущего чата.
@@ -183,39 +180,39 @@ async def chatid_cmd(message: Message) -> None:
"""
chat = message.chat
- output = "💬 ИНФОРМАЦИЯ О ЧАТЕ\n\n"
+ output = '💬 ИНФОРМАЦИЯ О ЧАТЕ\n\n'
# Тип чата
chat_types = {
- "private": "💬 Личные сообщения",
- "group": "👥 Группа",
- "supergroup": "👥 Супергруппа",
- "channel": "📢 Канал"
+ 'private': '💬 Личные сообщения',
+ 'group': '👥 Группа',
+ 'supergroup': '👥 Супергруппа',
+ 'channel': '📢 Канал'
}
- chat_type = chat_types.get(chat.type, "💬 Чат")
+ chat_type = chat_types.get(chat.type, '💬 Чат')
- output += f"📝 Тип: {chat_type}\n"
+ output += f'💠 Тип: {chat_type}\n'
if chat.title:
- output += f"📌 Название: {chat.title}\n"
+ output += f'📌 Название: {chat.title}\n'
if chat.username:
- output += f"🔗 Username: @{chat.username}\n"
+ output += f'💠 Username: @{chat.username}\n'
- output += f"🆔 Chat ID: {chat.id}\n"
+ output += f'💠 Chat ID: {chat.id}\n'
# Дополнительная информация для групп
- if chat.type in ["group", "supergroup"]:
+ if chat.type in ['group', 'supergroup']:
try:
member_count = await message.bot.get_chat_member_count(chat.id)
- output += f"👥 Участников: {member_count}\n"
+ output += f'👥 Участников: {member_count}\n'
except Exception as e:
- logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
+ logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
keyboard = get_close_keyboard()
await message.answer(
text=output,
- parse_mode="HTML",
+ parse_mode='HTML',
reply_markup=keyboard
)
diff --git a/bot/handlers/commands/users/listwords.py b/bot/handlers/commands/users/listwords.py
index 2673f4a..4f99db3 100644
--- a/bot/handlers/commands/users/listwords.py
+++ b/bot/handlers/commands/users/listwords.py
@@ -5,6 +5,7 @@ from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
@@ -123,7 +124,7 @@ async def format_banwords_list(page: int = 0) -> str:
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
if conflict_words or conflict_lemmas:
output += "⚔️ КОНФЛИКТНЫЕ ПРАВИЛА:\n"
- output += "(работают только в режиме /stopconflict)\n\n"
+ output += "(работают только в режиме /stopconflict время)\n\n"
if conflict_words:
output += f"📝 Конфликтные слова ({len(conflict_words)}):\n"
@@ -188,9 +189,7 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
"""
Обработчик команды /listwords.
Отображает список всех правил модерации с разбивкой по категориям.
-
Доступно только администраторам.
-
Args:
update: Message или CallbackQuery
"""
@@ -214,12 +213,18 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
keyboard = get_refresh_kb(page)
if is_callback:
- await message.edit_text(
- text=text,
- parse_mode="HTML",
- reply_markup=keyboard
- )
- await update.answer("✅ Список обновлён")
+ try:
+ await message.edit_text(
+ text=text,
+ parse_mode="HTML",
+ reply_markup=keyboard
+ )
+ await update.answer("✅ Список обновлён")
+ except TelegramBadRequest as e:
+ if 'message is not modified' in str(e).lower():
+ await update.answer('✅ Список уже актуален')
+ return
+ raise # Другие ошибки пробрасываем
else:
await message.answer(
text=text,
@@ -233,6 +238,6 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
error_text = "❌ Ошибка загрузки списка\n\nПопробуйте позже"
if is_callback:
- await update.answer("❌ Ошибка загрузки", show_alert=True)
+ await update.answer(f"❌ Ошибка загрузки: {e}", show_alert=True)
else:
await message.answer(error_text, parse_mode="HTML")
diff --git a/bot/handlers/commands/users/report.py b/bot/handlers/commands/users/report.py
index 8ca7df4..f5e4a64 100644
--- a/bot/handlers/commands/users/report.py
+++ b/bot/handlers/commands/users/report.py
@@ -2,11 +2,12 @@
Обработчики команды /report для пользователей
"""
from datetime import datetime
+
from aiogram import Router, F
+from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, User
from aiogram.utils.keyboard import InlineKeyboardBuilder
-from aiogram.exceptions import TelegramBadRequest
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
@@ -18,29 +19,13 @@ __all__ = ("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:
- """
- Форматирует информацию о пользователе.
-
- Args:
- user: Объект User
-
- Returns:
- Отформатированная строка с именем и username
- """
+ """Форматирует информацию о пользователе."""
if not user:
return "Unknown User"
- # Формируем имя
name_parts = []
if user.first_name:
name_parts.append(user.first_name)
@@ -49,11 +34,9 @@ def format_user(user: User) -> str:
full_name = " ".join(name_parts) if name_parts else "No Name"
- # Добавляем username если есть
if user.username:
return f"{full_name} (@{user.username})"
- else:
- return full_name
+ return full_name
def format_datetime(dt: datetime) -> str:
@@ -72,7 +55,8 @@ def get_report_keyboard(
chat_id: int,
message_id: int,
reported_user_id: int,
- report_id: str
+ report_id: str,
+ message_thread_id: int | None = None
) -> InlineKeyboardBuilder:
"""
Создает клавиатуру для репорта.
@@ -82,17 +66,19 @@ def get_report_keyboard(
message_id: ID сообщения
reported_user_id: ID пользователя, на которого пожаловались
report_id: Уникальный ID репорта
+ message_thread_id: ID топика (если есть)
"""
ikb = InlineKeyboardBuilder()
- # Кнопки действий
+ thread_id = message_thread_id if message_thread_id is not None else 0
+
ikb.button(
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(
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(
text="✅ Закрыть",
@@ -115,17 +101,10 @@ async def report_cmd(message: Message) -> None:
"""
Отправляет жалобу на сообщение администраторам.
- Доступно всем пользователям.
-
Использование:
/report — в ответ на сообщение
/report <причина> — в ответ на сообщение с указанием причины
-
- Пример:
- /report спам
- /report оскорбления
"""
- # Проверяем, что команда в ответ на сообщение
if not message.reply_to_message:
await message.answer(
"❌ Используйте команду в ответ на сообщение\n\n"
@@ -133,7 +112,7 @@ async def report_cmd(message: Message) -> None:
"1. Ответьте на сообщение нарушителя\n"
"2. Напишите /report или /report причина\n\n"
"Пример: /report спам",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
@@ -141,103 +120,104 @@ async def report_cmd(message: Message) -> None:
reported_user = reported_message.from_user
reporter = message.from_user
- # Проверка на None
if not reported_user or not reporter:
- await message.answer("❌ Ошибка получения данных пользователя", parse_mode="HTML")
+ await message.answer(
+ "❌ Ошибка получения данных пользователя",
+ parse_mode="HTML",
+ )
return
- # Нельзя пожаловаться на самого себя
if reported_user.id == reporter.id:
await message.answer(
"⚠️ Нельзя пожаловаться на самого себя",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
- # Нельзя пожаловаться на бота
if reported_user.is_bot:
await message.answer(
"⚠️ Нельзя пожаловаться на бота",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
- # Нельзя пожаловаться на администратора
manager = get_manager()
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
if is_admin:
await message.answer(
"⚠️ Нельзя пожаловаться на администратора",
- parse_mode="HTML"
+ parse_mode="HTML",
)
return
- # Извлекаем причину (опционально)
- parts = message.text.split(maxsplit=1)
+ parts = (message.text or "").split(maxsplit=1)
reason = parts[1] if len(parts) > 1 else "Не указана"
- # Генерируем ID репорта
report_id = generate_report_id()
- # === ФОРМИРУЕМ СООБЩЕНИЕ РЕПОРТА ===
+ # thread/topic исходного сообщения (если репортят из топика)
+ original_message_thread_id = reported_message.message_thread_id
report_text = "🚨 НОВЫЙ РЕПОРТ\n\n"
-
- # Информация о жалобщике
report_text += f"👤 От: {format_user(reporter)} ({reporter.id})\n"
-
- # Информация о нарушителе
report_text += f"⚠️ На: {format_user(reported_user)} ({reported_user.id})\n\n"
- # Информация о чате
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
report_text += f"💬 Чат: {chat_title}\n"
- report_text += f"🆔 Chat ID: {message.chat.id}\n\n"
+ report_text += f"🆔 Chat ID: {message.chat.id}\n"
+
+ if original_message_thread_id:
+ report_text += f"📌 Topic ID: {original_message_thread_id}\n"
+ report_text += "\n"
- # Причина
report_text += f"📝 Причина: {reason}\n\n"
+ report_text += "📄 Текст сообщения:\n"
- # Текст сообщения
- report_text += f"📄 Текст сообщения:\n"
-
+ message_content = None
if reported_message.text:
truncated_text = truncate_text(reported_message.text, max_length=300)
report_text += f"{truncated_text}\n\n"
+ message_content = reported_message.text
elif reported_message.caption:
truncated_caption = truncate_text(reported_message.caption, max_length=300)
report_text += f"{truncated_caption}\n\n"
+ message_content = reported_message.caption
else:
- content_type = reported_message.content_type
- report_text += f"[{content_type}]\n\n"
+ report_text += f"[{reported_message.content_type}]\n\n"
- # Время
report_text += f"🕐 Время: {format_datetime(datetime.now())}\n"
report_text += f"🔗 Message ID: {reported_message.message_id}\n\n"
-
report_text += f"💡 ID репорта: {report_id}"
- # Клавиатура
keyboard = get_report_keyboard(
chat_id=message.chat.id,
message_id=reported_message.message_id,
reported_user_id=reported_user.id,
- report_id=report_id
+ report_id=report_id,
+ message_thread_id=original_message_thread_id
)
- # === ОТПРАВКА РЕПОРТА ===
-
try:
- # Если указан админ-чат, отправляем туда
- if REPORT_CHAT_ID:
- await message.bot.send_message(
- chat_id=REPORT_CHAT_ID,
- text=report_text,
- parse_mode="HTML",
- reply_markup=keyboard.as_markup()
- )
+ report_chat_id = settings.REPORT_CHAT_ID
+ report_thread_id = settings.REPORT_THREAD_ID
+
+ # Нормализуем: 0 считаем как "без топика"
+ if report_thread_id == 0:
+ report_thread_id = None
+
+ 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:
- # Отправляем всем владельцам
sent_count = 0
for owner_id in settings.OWNER_ID:
try:
@@ -254,24 +234,38 @@ async def report_cmd(message: Message) -> None:
if sent_count == 0:
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(
"✅ Жалоба отправлена администраторам\n\n"
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
- parse_mode="HTML"
+ parse_mode="HTML",
)
- # Логирование
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"
)
except Exception as e:
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
await message.answer(
- "❌ Ошибка отправки жалобы\n\nПопробуйте позже или обратитесь к администратору напрямую.",
- parse_mode="HTML"
+ "❌ Ошибка отправки жалобы\n\n"
+ "Попробуйте позже или обратитесь к администратору напрямую.",
+ parse_mode="HTML",
)
@@ -280,30 +274,28 @@ async def report_cmd(message: Message) -> None:
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
async def report_ban_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Забанить'"""
+ manager = get_manager()
+
try:
- # Парсим данные: report:ban:chat_id:user_id:report_id
- parts = callback.data.split(":")
+ parts = (callback.data or "").split(":")
chat_id = int(parts[2])
user_id = int(parts[3])
report_id = parts[4]
- # Баним пользователя
try:
- await callback.bot.ban_chat_member(
- chat_id=chat_id,
- user_id=user_id
+ await callback.bot.ban_chat_member(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)
+ updated_text = (callback.message.text if callback.message else "") + f"\n\n✅ Пользователь забанен ({admin_name})"
- # Обновляем сообщение
- updated_text = callback.message.text + f"\n\n✅ Пользователь забанен ({admin_name})"
-
- # Убираем кнопки
- await callback.message.edit_text(
- text=updated_text,
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(text=updated_text, parse_mode="HTML")
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())
async def report_delete_callback(callback: CallbackQuery) -> None:
"""Обрабатывает нажатие кнопки 'Удалить'"""
+ manager = get_manager()
+
try:
- # Парсим данные: report:delete:chat_id:message_id:report_id
- parts = callback.data.split(":")
+ parts = (callback.data or "").split(":")
chat_id = int(parts[2])
message_id = int(parts[3])
report_id = parts[4]
- # Удаляем сообщение
try:
- await callback.bot.delete_message(
- chat_id=chat_id,
- message_id=message_id
+ await callback.bot.delete_message(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)
+ updated_text = (callback.message.text if callback.message else "") + f"\n\n🗑 Сообщение удалено ({admin_name})"
- # Обновляем сообщение
- updated_text = callback.message.text + f"\n\n🗑 Сообщение удалено ({admin_name})"
-
- # Убираем кнопки
- await callback.message.edit_text(
- text=updated_text,
- parse_mode="HTML"
- )
+ if callback.message:
+ await callback.message.edit_text(text=updated_text, parse_mode="HTML")
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())
async def report_close_callback(callback: CallbackQuery) -> None:
- """Обрабатывает нажатие кнопки 'Закрыть'"""
+ """Обрабатывает нажатие кнопки 'Закрыть' (и удаляет сообщение репорта)"""
+ manager = get_manager()
+
try:
- # Парсим данные: report:close:report_id
- parts = callback.data.split(":")
+ parts = (callback.data or "").split(":")
report_id = parts[2]
- admin_name = format_user(callback.from_user)
-
- # Обновляем сообщение
- updated_text = callback.message.text + f"\n\n✅ Репорт закрыт ({admin_name})"
-
- # Убираем кнопки
- await callback.message.edit_text(
- text=updated_text,
- parse_mode="HTML"
+ await manager.repo.update_report_status(
+ report_id=report_id,
+ status="closed",
+ processed_by=callback.from_user.id
)
await callback.answer("✅ Репорт закрыт")
+ # Удаляем сообщение с репортом в админ-чате/топике
+ if callback.message:
+ try:
+ await callback.message.delete()
+ except TelegramBadRequest as e:
+ logger.warning(f"Не удалось удалить сообщение репорта: {e}", log_type="REPORT")
+
logger.info(
f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
log_type="REPORT"
@@ -394,15 +387,11 @@ async def report_close_callback(callback: CallbackQuery) -> None:
await callback.answer("❌ Ошибка выполнения", show_alert=True)
-# ================= ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ =================
+# ================= ДОПОЛНИТЕЛЬНЫЕ КОМАНДЫ =================
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
async def report_help_cmd(message: Message) -> None:
- """
- Показывает справку по системе репортов.
-
- Доступно всем пользователям.
- """
+ """Показывает справку по системе репортов."""
text = (
"🚨 СИСТЕМА РЕПОРТОВ\n\n"
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
@@ -425,23 +414,44 @@ async def report_help_cmd(message: Message) -> None:
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:
- """
- Показывает статистику по репортам (для админов).
+ """Показывает статистику по репортам (для админов)"""
+ manager = get_manager()
- TODO: Реализовать сохранение статистики в БД
- """
- text = (
- "📊 СТАТИСТИКА РЕПОРТОВ\n\n"
- "⚠️ Функция в разработке\n\n"
- "Планируется:\n"
- "• Всего репортов за всё время\n"
- "• Топ жалобщиков\n"
- "• Топ нарушителей\n"
- "• Распределение по причинам\n"
- "• Статистика обработки\n\n"
- "💡 Для реализации нужно добавить таблицу reports в БД"
- )
+ stats = await manager.repo.get_report_stats()
+ top_reporters = await manager.repo.get_top_reporters(limit=5)
+ top_reported = await manager.repo.get_top_reported_users(limit=5)
+
+ if not stats:
+ await message.answer("❌ Ошибка получения статистики", parse_mode="HTML")
+ return
+
+ text = "📊 СТАТИСТИКА РЕПОРТОВ\n\n"
+ text += "📈 Общая статистика:\n"
+ text += f"├─ Всего репортов: {stats.get('total', 0)}\n"
+ text += f"├─ В ожидании: {stats.get('pending', 0)}\n"
+ text += f"├─ Закрыто: {stats.get('closed', 0)}\n"
+ text += f"├─ Забанено: {stats.get('banned', 0)}\n"
+ text += f"└─ Удалено: {stats.get('deleted', 0)}\n\n"
+
+ if top_reporters:
+ text += "👥 Топ жалобщиков:\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} — {count} реп.\n"
+ text += "\n"
+
+ if top_reported:
+ text += "⚠️ Топ нарушителей:\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} — {count} жалоб\n"
+ text += "\n"
+
+ text += f"🕐 Обновлено: {format_datetime(datetime.now())}"
await message.answer(text, parse_mode="HTML")
diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py
index 8a5464e..a8aa4c1 100644
--- a/bot/handlers/commands/users/start_cmd.py
+++ b/bot/handlers/commands/users/start_cmd.py
@@ -10,10 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot.filters.admin import IsAdmin
from configs import settings, COMMANDS
from middleware.loggers import logger
-from bot.utils.decorators import log_action
+from bot.utils import log_action, tg_emoji
__all__ = ("router",)
+
CMD: str = "start"
+
router: Router = Router(name="start_cmd_router")
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)
return ikb.as_markup()
-
@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)
@@ -36,6 +37,7 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
update: Message или CallbackQuery
"""
print(123)
+
# Определяем тип update и извлекаем данные
if isinstance(update, CallbackQuery):
message = update.message
@@ -51,98 +53,89 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
# Формируем текст помощи
help_text = (
- "🤖 PrimoGuard - Бот-модератор\n\n"
- "Автоматическое удаление сообщений с запрещёнными словами.\n"
- "Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
+ f'{tg_emoji(4961073056677103064)} PrimoGuard - Бот-модератор\n\n'
+ 'Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.
\n\n'
)
# === Команды просмотра ===
help_text += (
- "📋 Просмотр:\n"
- "/list — список всех правил и слов\n"
- "/stats — статистика по удалениям\n"
- "/id — получение айди пользователя\n"
- "/chatid — получение айди чата\n\n"
+ f'{tg_emoji(4961141003059725568)} Просмотр:\n'
+ '/list — список всех правил и слов\n'
+ '/stats — статистика по удалениям\n'
+ '/id — получение айди пользователя\n'
+ '/chatid — получение айди чата\n\n'
)
# === Постоянные банворды ===
help_text += (
- "➕ Добавить банворд (постоянно):\n"
- "/addword слово — подстрока (простой поиск)\n"
- "/addlemma слово — лемма (все формы слова)\n"
- "/addpart комбинация — часть (поиск без пробелов)\n\n"
+ f'{tg_emoji(4961019408240608234)} Добавить банворд (постоянно):\n'
+ '/addword слово — подстрока (простой поиск)\n'
+ '/addlemma слово — лемма (все формы слова)\n'
+ '/addpart комбинация — часть (поиск без пробелов)\n\n'
)
# === Временные банворды ===
help_text += (
- "⏱ Добавить банворд (временно):\n"
- "/addtempword слово минуты — временная подстрока\n"
- "/addtemplemma слово минуты — временная лемма\n"
- "Пример: /addtempword спам 60\n\n"
+ f'{tg_emoji(4960719190026618714)} Добавить банворд (временно):\n'
+ '/addtempword слово минуты — временная подстрока\n'
+ '/addtemplemma слово минуты — временная лемма\n'
+ 'Пример: /addtempword спам 60\n\n'
)
# === Исключения (whitelist) ===
help_text += (
- "✅ Исключения (whitelist):\n"
- "/addexcept текст — добавить исключение\n"
- "/remexcept текст — удалить исключение\n"
- "Исключения не проверяются фильтром\n\n"
+ f'{tg_emoji(4963010134172239128)} Исключения (whitelist):\n'
+ '/addexcept текст — добавить исключение\n'
+ '/remexcept текст — удалить исключение\n'
+ 'Исключения не проверяются фильтром\n\n'
)
# === Режимы модерации ===
help_text += (
- "🔇 Режим тишины:\n"
- "/silence минуты — удалять ВСЕ сообщения\n"
- "/unsilence — отключить режим тишины\n"
- "/report — отправить репорт\n\n"
+ f'{tg_emoji(4960987543878239236)} Режим тишины:\n'
+ '/silence минуты — удалять ВСЕ сообщения\n'
+ '/unsilence — отключить режим тишины\n'
+ '/report — отправить репорт\n\n'
)
help_text += (
- "⚔️ Режим антиконфликта:\n"
- "/addconflictword слово — добавить конфликтное слово\n"
- "/addconflictlemma слово — добавить конфликтную лемму\n"
- "/stopconflict минуты — активировать режим\n"
- "/unstopconflict — отключить режим\n\n"
+ f'{tg_emoji(4960986152308835400)} Режим антиконфликта:\n'
+ '/addconflictword слово — добавить конфликтное слово\n'
+ '/addconflictlemma слово — добавить конфликтную лемму\n'
+ '/stopconflict минуты — активировать режим\n'
+ '/unstopconflict — отключить режим\n\n'
)
# === Удаление ===
help_text += (
- "➖ Удалить:\n"
- "/remword слово — удалить подстроку\n"
- "/remlemma слово — удалить лемму\n"
- "/rempart комбинация — удалить часть\n"
- "/remtempword слово — удалить временную подстроку\n"
- "/remtemplemma слово — удалить временную лемму\n"
- "/remconflictword слово — удалить конфликтное слово\n"
- "/remconflictlemma слово — удалить конфликтную лемму\n\n"
+ f'{tg_emoji(4961196485447254983)} Удалить:\n'
+ '/remword слово — удалить подстроку\n'
+ '/remlemma слово — удалить лемму\n'
+ '/rempart комбинация — удалить часть\n'
+ '/remtempword слово — удалить временную подстроку\n'
+ '/remtemplemma слово — удалить временную лемму\n'
+ '/remconflictword слово — удалить конфликтное слово\n'
+ '/remconflictlemma слово — удалить конфликтную лемму\n\n'
)
# === Управление админами (только для суперадминов) ===
if is_super_admin:
help_text += (
- "👑 Управление админами (только для владельцев):\n"
- "/addadmin ID — добавить администратора\n"
- "/remadmin ID — удалить администратора\n"
- "/listadmins — список всех админов\n\n"
+ f'{tg_emoji(4960891456869893259)} Управление админами (только для владельцев):\n'
+ '/addadmin ID — добавить администратора\n'
+ '/remadmin ID — удалить администратора\n'
+ '/redactcomment — изменить комментарий под постом\n'
+ '/listadmins — список всех админов\n\n'
)
# === Типы проверок ===
help_text += (
- "ℹ️ Типы проверок:\n"
- "• Подстрока — простой поиск в тексте\n"
- "• Лемма — все формы слова (купить→куплю, купил, купишь...)\n"
- "• Часть — поиск без пробелов (обходит \"к у п и т ь\")\n"
- "• Временные — автоматически удаляются через N минут\n"
- "• Конфликтные — работают только в режиме /stopconflict\n\n"
- )
-
- help_text += (
- "🔧 Технологии:\n"
- "• Unicode-нормализация (латиница→кириллица)\n"
- "• Обход через разделители (\"с п а м\" → \"спам\")\n"
- "• Морфологический анализ (pymorphy3)\n"
- "• SQLAlchemy + SQLite с кэшированием\n\n"
- "💾 Все настройки сохраняются в базе данных"
+ f'{tg_emoji(4961021096162755737)} Типы проверок:\n'
+ '• Подстрока — простой поиск в тексте\n'
+ '• Лемма — все формы слова (купить→куплю, купил, купишь...)\n'
+ '• Часть — поиск без пробелов (обходит \"к у п и т ь\")\n'
+ '• Временные — автоматически удаляются через N минут\n'
+ '• Конфликтные — работают только в режиме /stopconflict\n\n'
)
# Отправляем ответ
@@ -166,4 +159,4 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
log_type="ERROR"
)
if is_callback:
- await update.answer("❌ Ошибка отображения справки", show_alert=True)
+ await update.answer(f'{tg_emoji(4963277744994518278)} Ошибка отображения справки', show_alert=True)
diff --git a/bot/handlers/messages/default_msg.py b/bot/handlers/messages/default_msg.py
index 517c143..9e742ff 100644
--- a/bot/handlers/messages/default_msg.py
+++ b/bot/handlers/messages/default_msg.py
@@ -1,11 +1,242 @@
+"""
+Триггер-хэндлер: реагирует на обращения к Лайле с именем персонажа.
+Формат: "Лайла [что угодно] [имя или псевдоним]"
+"""
+from typing import Dict, List, Optional
+import random
+
from aiogram import Router
from aiogram.types import Message
-# Настройки экспорта и роутера
-router: Router = Router(name=__name__)
+__all__ = ("router",)
+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()
-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
diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py
deleted file mode 100644
index ca2e2a9..0000000
--- a/bot/keyboards/inline/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .decision import *
diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py
deleted file mode 100644
index e9bb032..0000000
--- a/bot/keyboards/inline/decision.py
+++ /dev/null
@@ -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()
diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/bot/middlewares/banwords_mdw.py b/bot/middlewares/banwords_mdw.py
index 38225aa..baf1683 100644
--- a/bot/middlewares/banwords_mdw.py
+++ b/bot/middlewares/banwords_mdw.py
@@ -1,19 +1,13 @@
"""
Middleware для проверки сообщений на запрещённые слова (банворды).
-
-Pipeline проверки:
-1. Пропускаем админов и служебные сообщения
-2. Проверяем whitelist (исключения)
-3. Проверяем режим silence (удаляем всё)
-4. Проверяем режим conflict (конфликтные слова)
-5. Проверяем постоянные банворды (substring, lemma, part)
-6. Проверяем временные банворды
-7. Если найдено - удаляем, логируем, уведомляем админов
-
-НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
+...
+✅ ИСПРАВЛЕНО:
+- ❌ PatternError: bad character range 🀀-\\\\ (исправлено экранирование Unicode)
+- ✅ НЕТ уведомлений в режиме тишины
"""
from typing import Callable, Dict, Any, Awaitable, Optional
import re
+import unicodedata
from aiogram import BaseMiddleware
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
@@ -28,263 +22,162 @@ __all__ = ("BanWordsMiddleware",)
class BanWordsMiddleware(BaseMiddleware):
- """
- Middleware для фильтрации сообщений с банвордами.
-
- Проверяет каждое текстовое сообщение на наличие запрещённых слов,
- удаляет спам и уведомляет администраторов.
- """
-
def __init__(self):
- """Инициализирует middleware"""
super().__init__()
self.manager = get_manager()
async def __call__(
- self,
- handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
- event: Message,
- data: Dict[str, Any]
+ self,
+ handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
+ event: Message,
+ data: Dict[str, Any]
) -> Any:
- """
- Обрабатывает входящие сообщения.
-
- Args:
- handler: Следующий обработчик в цепочке
- event: Сообщение от пользователя
- data: Данные из диспетчера
-
- Returns:
- Any: Результат обработчика или None (если сообщение удалено)
- """
- # Пропускаем не-текстовые сообщения
if not event.text and not event.caption:
return await handler(event, data)
- # Получаем текст (из text или caption)
message_text = event.text or event.caption
-
- # Пропускаем команды (начинаются с /)
if message_text.startswith('/'):
return await handler(event, data)
- # Проверяем, является ли пользователь админом
+ # Админ проверка
user_id = event.from_user.id
is_super_admin = user_id in settings.OWNER_ID
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
-
- # Админы пропускаются
if is_admin:
return await handler(event, data)
- # Проверяем сообщение на банворды
spam_result = await self._check_message(message_text)
-
if spam_result:
- # Найден спам - удаляем и уведомляем
await self._handle_spam(event, spam_result)
- return None # Не продолжаем обработку
+ return None
- # Сообщение чистое - пропускаем дальше
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())
+ def _normalize_universal(text: str, mode: str = "strict") -> str:
+ """✅ ИСПРАВЛЕНО: Универсальная нормализация для всех типов проверок"""
+ # БЕЗОПАСНАЯ нормализация - убираем все проблемные символы
+ text = unicodedata.normalize('NFKC', text)
+
+ if mode == "strict": # PART - сохраняем буквы, цифры, пробелы
+ # ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв/цифр/пробелов
+ text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9\s]', '', text)
+ text = re.sub(r'\s+', ' ', text).strip()
+ else: # SUBSTRING/LEMMA - только буквы и цифры
+ text = re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text)
+
+ return BanWordsMiddleware._normalize_repeated_chars(text)
@staticmethod
- def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str:
- """
- Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог").
+ def _normalize_repeated_chars(text: str) -> str:
+ """Убирает повторения >2 (лееееин → лейн)"""
+ return re.sub(r'([а-яёa-z])\\1{2,}', r'\\1\\1', text, flags=re.IGNORECASE)
- 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)
+ def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]:
+ """🔥 Блокирует 3+ повторяющиеся символы подряд"""
+ # ✅ ИСПРАВЛЕНО: безопасный паттерн только для букв
+ pattern = r'([а-яёa-zA-Z])\\1{2,}'
+ matches = re.finditer(pattern, text, flags=re.IGNORECASE)
+
+ 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 _check_message(self, text: str) -> Optional[Dict[str, str]]:
- """
- Проверяет сообщение на наличие банвордов.
-
- Args:
- text: Текст сообщения
-
- Returns:
- Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
- """
- # Нормализуем текст для проверки
text_lower = text.lower()
+
+ # 🔥 1. Повторяющиеся символы (лееееин)
+ repeat_result = self._check_repeated_chars(text_lower)
+ if repeat_result:
+ return repeat_result
+
+ # 2. ✅ БЕЗОПАСНАЯ нормализация
+ text_universal = self._normalize_universal(text_lower, "strict") # PART
+ text_loose = self._normalize_universal(text_lower) # SUBSTRING/LEMMA
text_processed = process_text(text_lower)
- # Дополнительно нормализуем повторяющиеся буквы для всех проверок
- text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1)
-
logger.debug(
- f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', "
- f"нормализованный='{text_normalized[:50]}'",
+ f"🔍 | universal='{text_universal}' | loose='{text_loose}' | proc='{text_processed}'",
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"
- )
+ # 3. WHITELIST
+ if (self.manager.is_whitelisted(text_processed) or
+ self.manager.is_whitelisted(text_loose) or
+ self.manager.is_whitelisted(text_universal)):
return None
- # === 2. SILENCE MODE (удаляем всё) ===
+ # 4. SILENCE MODE
if await self.manager.is_silence_active():
- return {
- "word": "[режим тишины]",
- "type": "silence"
- }
+ return {"word": "[режим тишины]", "type": "silence"}
- # === 3. CONFLICT MODE (конфликтные слова) ===
+ # 5. CONFLICT MODE
if await self.manager.is_conflict_active():
- # Проверяем конфликтные подстроки (с нормализацией)
- conflict_substring = self.manager.get_banwords_cached(
- BanWordType.CONFLICT_SUBSTRING
- )
- for word in conflict_substring:
- word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
- if word_normalized in text_normalized:
+ for word in self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING):
+ word_norm = self._normalize_universal(word.lower(), "loose")
+ if word_norm in text_loose:
return {"word": word, "type": "conflict_substring"}
- # Проверяем конфликтные леммы
- conflict_lemma = self.manager.get_banwords_cached(
- BanWordType.CONFLICT_LEMMA
- )
- 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)
-
+ conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
+ for word_text in extract_words(text_processed):
+ lemma = get_lemma(self._normalize_repeated_chars(word_text))
if lemma in 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"
- )
+ # 6. SUBSTRING
+ for word in self.manager.get_banwords_cached(BanWordType.SUBSTRING):
+ word_norm = self._normalize_universal(word.lower())
+ if word_norm in text_loose:
+ logger.info(f"✅ SUBSTRING: '{word}' → '{text_loose}'", 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)
+ # 7. PART (строгая нормализация)
+ for part in self.manager.get_banwords_cached(BanWordType.PART):
+ part_norm = self._normalize_universal(part.lower(), "strict")
+ if part_norm in text_universal:
+ logger.info(f"✅ PART: '{part}' → '{text_universal}'", log_type="BANWORDS")
+ return {"word": part, "type": "part"}
- for part in part_words:
- part_normalized = self._normalize_for_part_check(part)
- part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1)
+ # 8. LEMMA
+ for word_text in extract_words(text_processed):
+ lemma = get_lemma(self._normalize_repeated_chars(word_text))
+ if lemma in self.manager.get_banwords_cached(BanWordType.LEMMA):
+ logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS")
+ return {"word": lemma, "type": "lemma"}
- 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
- async def _handle_spam(
- self,
- message: Message,
- spam_result: Dict[str, str]
- ) -> None:
- """
- Обрабатывает найденный спам: удаляет, логирует, уведомляет.
-
- Args:
- message: Сообщение со спамом
- spam_result: Результат проверки (слово + тип)
- """
+ async def _handle_spam(self, message: Message, spam_result: Dict[str, str]) -> None:
user = message.from_user
matched_word = spam_result["word"]
match_type = spam_result["type"]
-
- # Получаем текст сообщения
message_text = message.text or message.caption or "[нет текста]"
- # === 1. УДАЛЯЕМ СООБЩЕНИЕ ===
- try:
- await message.delete()
- logger.info(
- 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:
- logger.error(
- f"Не удалось удалить сообщение: {e}",
- log_type="BANWORDS",
- user=f"@{user.username}" if user.username else f"id{user.id}"
- )
+ # ✅ ПРОВЕРКА: НЕ отправляем уведомления в режиме тишины
+ if match_type == "silence":
+ # Удаляем сообщение молча
+ try:
+ await message.delete()
+ logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча",
+ log_type="BANWORDS")
+ except TelegramBadRequest as e:
+ logger.error(f"❌ Не удалено (silence): {e}", log_type="BANWORDS")
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
+
+ # Логируем в БД (только НЕ silence)
await self.manager.log_spam(
user_id=user.id,
username=user.username or f"id{user.id}",
@@ -294,96 +187,71 @@ class BanWordsMiddleware(BaseMiddleware):
match_type=match_type
)
- # === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
+ # Уведомляем админов (только НЕ silence)
await self._notify_admins(message, matched_word, match_type, message_text)
+ # Остальные методы без изменений...
async def _notify_admins(
- self,
- message: Message,
- matched_word: str,
- match_type: str,
- message_text: str
+ self,
+ message: Message,
+ matched_word: str,
+ match_type: str,
+ message_text: str
) -> None:
- """
- Отправляет уведомление в админский чат с кнопками.
-
- Args:
- message: Удалённое сообщение
- matched_word: Слово, по которому сработал фильтр
- match_type: Тип проверки
- message_text: Текст сообщения
- """
user = message.from_user
username = f"@{user.username}" if user.username else f"ID: {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 = (
- f"🚫 Удалено сообщение\n\n"
- f"👤 Пользователь: {username}\n"
- f"🆔 ID: {user.id}\n"
- f"📊 Нарушений: {spam_count}\n\n"
- f"🔍 Триггер: {matched_word}\n"
- f"📝 Тип: {self._get_type_emoji(match_type)} {match_type}\n\n"
- f"💬 Текст:\n"
- f"{self._escape_html(message_text[:500])}"
+ f"🚫 Удалено сообщение\\n\\n"
+ f"👤 Пользователь: {username}\\n"
+ f"🆔 ID: {user.id}\\n"
+ f"📊 Нарушений: {spam_count}\\n\\n"
+ f"💬 Чат: {self._escape_html(chat_title)}\\n"
+ f"🆔 Chat ID: {message.chat.id}\\n"
+ f"{'📌 Topic ID: {source_thread_id}\\n' if source_thread_id else ''}"
+ f"🔗 Message ID: {message.message_id}\\n\\n"
+ f"🔍 Триггер: {self._escape_html(matched_word)}\\n"
+ f"📝 Тип: {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\\n\\n"
+ f"💬 Текст:\\n{self._escape_html(message_text[:500])}"
)
- # Создаём клавиатуру с действиями
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
- InlineKeyboardButton(
- text="🔨 Забанить",
- callback_data=f"spam_ban:{user.id}:{message.chat.id}"
- ),
- InlineKeyboardButton(
- text="✅ Закрыть",
- callback_data="spam_close"
- )
+ InlineKeyboardButton(text="🔨 Забанить", 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:
- bot = message.bot
- await bot.send_message(
- chat_id=settings.ADMIN_CHAT_ID,
+ admin_chat_id = getattr(settings, "ADMIN_CHAT_ID", None)
+ admin_thread_id = getattr(settings, "ADMIN_THREAD_ID", None) or None
+
+ await message.bot.send_message(
+ chat_id=admin_chat_id,
text=notification_text,
reply_markup=keyboard,
- parse_mode="HTML"
+ parse_mode="HTML",
+ message_thread_id=admin_thread_id
)
except Exception as e:
- logger.error(
- f"Ошибка отправки уведомления админам: {e}",
- log_type="BANWORDS"
- )
+ logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS")
@staticmethod
def _get_type_emoji(match_type: str) -> str:
- """Возвращает эмодзи для типа проверки"""
- emoji_map = {
+ return {
"substring": "🔤",
"lemma": "📖",
"part": "🧩",
"silence": "🔇",
"conflict_substring": "⚔️",
- "conflict_lemma": "⚔️"
- }
- return emoji_map.get(match_type, "❓")
+ "conflict_lemma": "⚔️",
+ "repeated_chars": "🔁"
+ }.get(match_type, "❓")
@staticmethod
def _escape_html(text: str) -> str:
- """Экранирует HTML символы для безопасного отображения"""
- return (
- text.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- )
+ return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py
index 56dd14b..b7eb79c 100644
--- a/bot/middlewares/spam_mdw.py
+++ b/bot/middlewares/spam_mdw.py
@@ -68,7 +68,6 @@ class UserSpamStats:
"""Удаляет старые запросы за пределами временного окна"""
cutoff_time = current_time - time_window
- # Удаляем старые запросы
new_times = []
new_contexts = []
@@ -121,7 +120,6 @@ class UserSpamStats:
current_time = time()
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
- # Если 5+ сообщений за 2 секунды => мгновенный мут
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
if len(very_recent) >= 5:
return {
@@ -130,7 +128,7 @@ class UserSpamStats:
'severity': 1.0,
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
'instant_block': True,
- 'block_duration': 600.0 # 10 минут сразу
+ 'block_duration': 600.0
}
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
@@ -142,13 +140,12 @@ class UserSpamStats:
'severity': 0.95,
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
'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]
if len(media_contexts) >= 7:
- # Проверяем скорость отправки медиа
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
if len(media_recent) >= 6:
return {
@@ -157,7 +154,7 @@ class UserSpamStats:
'severity': 0.9,
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
'instant_block': True,
- 'block_duration': 240.0 # 4 минуты
+ 'block_duration': 240.0
}
return {
@@ -173,14 +170,14 @@ class UserSpamStats:
text_counts = Counter(texts)
most_common_text, count = text_counts.most_common(1)[0]
- if count >= 5: # 5 одинаковых сообщений
+ if count >= 5:
return {
'is_spam': True,
'reason': 'identical_messages',
'severity': 0.85,
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
'instant_block': True,
- 'block_duration': 180.0 # 3 минуты
+ 'block_duration': 180.0
}
# 5. Проверка спама callback кнопок
@@ -189,14 +186,14 @@ class UserSpamStats:
callback_counts = Counter(callbacks)
most_common_callback, count = callback_counts.most_common(1)[0]
- if count >= 10: # 10 нажатий одной кнопки
+ if count >= 10:
return {
'is_spam': True,
'reason': 'callback_spam',
'severity': 0.8,
'details': f"🔘 Спам кнопки: {count} нажатий",
'instant_block': True,
- 'block_duration': 120.0 # 2 минуты
+ 'block_duration': 120.0
}
return {'is_spam': False, 'reason': None, 'severity': 0.0}
@@ -269,11 +266,11 @@ class AntiSpamMiddleware(BaseMiddleware):
- Детекция скорости отправки сообщений
- Адаптивная длительность блокировки
- Различает типы активности
+ - Бот никогда не банит сам себя
"""
def __init__(
self,
- # Базовые лимиты (мягкие, для накопления варнингов)
rate_limit_text: int = 8,
rate_limit_forward: int = 20,
rate_limit_callback: int = 12,
@@ -281,12 +278,10 @@ class AntiSpamMiddleware(BaseMiddleware):
time_window: float = 10.0,
- # Предупреждения (уже не так важны — флуд блокируется мгновенно)
warning_limit: int = 3,
- base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги
+ base_block_duration: float = 120.0,
max_block_duration: float = 3600.0,
- # Опции
whitelist_admins: bool = True,
progressive_blocking: 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_command = bool(context.text and context.text.startswith('/'))
- # Определяем тип медиа
if event.photo:
context.media_type = 'photo'
elif event.video:
@@ -350,7 +344,6 @@ class AntiSpamMiddleware(BaseMiddleware):
else:
base_limit = self.rate_limit_text
- # Применяем репутацию
if self.enable_reputation:
base_limit = int(base_limit * user_stats.reputation)
@@ -392,6 +385,11 @@ class AntiSpamMiddleware(BaseMiddleware):
if user_id is None:
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}"
# Whitelist для администраторов
@@ -414,7 +412,7 @@ class AntiSpamMiddleware(BaseMiddleware):
user=user_str
)
- # НЕ отправляем сообщение каждый раз — только callback answer
+ # Только для callback — отвечаем алертом, для сообщений молчим
if isinstance(event, CallbackQuery):
await event.answer(
f"🚫 Блокировка: {self._format_duration(remaining)}",
@@ -426,10 +424,10 @@ class AntiSpamMiddleware(BaseMiddleware):
# Извлекаем контекст сообщения
context = self._extract_context(event)
- # Добавляем запрос СНАЧАЛА (важно для детекции скорости)
+ # Добавляем запрос СНАЧАЛА — важно для детекции скорости флуда
user_stats.add_request(current_time, context)
- # Очищаем старые запросы
+ # Очищаем старые запросы за пределами временного окна
user_stats.clean_old_requests(current_time, self.time_window)
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
@@ -437,10 +435,9 @@ class AntiSpamMiddleware(BaseMiddleware):
spam_analysis = user_stats.detect_spam_patterns(self.time_window)
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
- # МГНОВЕННАЯ БЛОКИРОВКА
block_duration = spam_analysis.get('block_duration', 300.0)
user_stats.block(current_time, block_duration)
- user_stats.warnings = self.warning_limit # Максимум варнингов
+ user_stats.warnings = self.warning_limit
spam_stats.instant_blocks += 1
logger.error(
@@ -461,7 +458,7 @@ class AntiSpamMiddleware(BaseMiddleware):
if isinstance(event, Message):
try:
await event.answer(block_message, parse_mode="HTML")
- except:
+ except Exception:
pass
elif isinstance(event, CallbackQuery):
await event.answer(
@@ -471,10 +468,9 @@ class AntiSpamMiddleware(BaseMiddleware):
return None
- # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ==========
+ # ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ ==========
effective_limit = self._get_effective_rate_limit(user_stats, context)
- # Подсчитываем релевантные запросы
relevant_requests = 0
for req_context in user_stats.message_contexts:
if context.is_forward and req_context.is_forward:
@@ -493,7 +489,6 @@ class AntiSpamMiddleware(BaseMiddleware):
user=user_str
)
- # Мягкое превышение лимита
if relevant_requests >= effective_limit:
user_stats.add_warning()
spam_stats.total_warnings_issued += 1
@@ -505,7 +500,6 @@ class AntiSpamMiddleware(BaseMiddleware):
user=user_str
)
- # Блокировка при достижении лимита варнингов
if user_stats.warnings >= self.warning_limit:
block_duration = self._calculate_block_duration(user_stats.warnings)
user_stats.block(current_time, block_duration)
@@ -526,7 +520,7 @@ class AntiSpamMiddleware(BaseMiddleware):
if isinstance(event, Message):
try:
await event.answer(block_message, parse_mode="HTML")
- except:
+ except Exception:
pass
elif isinstance(event, CallbackQuery):
await event.answer(
@@ -536,7 +530,6 @@ class AntiSpamMiddleware(BaseMiddleware):
return None
- # Предупреждение (только для сообщений, не для callback)
if isinstance(event, Message):
warning_message = (
f"⚠️ Предупреждение {user_stats.warnings}/{self.warning_limit}\n\n"
@@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware):
)
try:
await event.answer(warning_message, parse_mode="HTML")
- except:
+ except Exception:
pass
return None
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index bfe2697..0f2beba 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -36,3 +36,4 @@ from .auto_delete import *
# ================= DECORATORS =================
from .decorators import *
+from .telegram_emoji import *
diff --git a/bot/utils/telegram_emoji.py b/bot/utils/telegram_emoji.py
new file mode 100644
index 0000000..cfcd64f
--- /dev/null
+++ b/bot/utils/telegram_emoji.py
@@ -0,0 +1,4 @@
+def tg_emoji(id: str, emoji: str = "💠") -> str:
+ """Генерирует HTML-тег кастомного эмодзи."""
+ return f'{emoji}'
+
\ No newline at end of file
diff --git a/configs/cmd_alias_list.py b/configs/cmd_alias_list.py
index b7ad3b9..3519084 100644
--- a/configs/cmd_alias_list.py
+++ b/configs/cmd_alias_list.py
@@ -10,6 +10,12 @@ COMMANDS: Final[dict[str, list[str]]] = {
"ыефке", "cnfhn", "gjxfnb", # раскладка
"st", "on", "вкл", # сокращения
],
+
+ "stop": [
+ "stop", "стоп", "завершить", # основные
+ "off", "ыещз", "cnjg", "pfdthibnm", # раскладка + сокращение
+ "щаа", # сокращения
+ ],
"help": [
"help", "помощь", "допомога", # основные
@@ -30,335 +36,329 @@ COMMANDS: Final[dict[str, list[str]]] = {
],
# ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ====================
-
"addword": [
"addword", "добавитьслово", # основные
"фввцщкв", "lj,fdbnmckjdj", # раскладка
- "aw", "addw", "добслово", # сокращения
+ "aw", "addw", "добслово", "word", # сокращения
],
"addlemma": [
"addlemma", "добавитьлемму", # основные
"фввдуььф", "lj,fdbnmktve", # раскладка
- "al", "addl", "доблемму", # сокращения
+ "al", "addl", "доблемму", "lemma", "lem", "lema",
],
"addpart": [
"addpart", "добавитьчасть", # основные
"фввзфке", "lj,fdbnmxfcnm", # раскладка
- "ap", "addp", "добчасть", # сокращения
+ "ap", "addp", "добчасть", "part",
],
# ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ====================
-
"addtempword": [
"addtempword", "добавитьвремслово", # основные
"фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка
- "atw", "addtw", "темпслово", # сокращения
+ "atw", "addtw", "темпслово", "addtword", "tempword", "tword",
],
"addtemplemma": [
"addtemplemma", "добавитьвремлемму", # основные
"фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка
- "atl", "addtl", "темплемму", # сокращения
+ "atl", "addtl", "темплемму", "addtlem", "addtemplem",
],
# ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
-
"addexcept": [
"addexcept", "добавитьисключение", # основные
"фввучсузе", "lj,fdbnmbcrkx", # раскладка
- "axc", "addwhite", "искл", # сокращения
+ "axc", "addwhite", "искл", "except", "white",
],
# ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ====================
-
"remword": [
"remword", "удалитьслово", # основные
"кутцщкв", "elfkbnmckjdj", # раскладка
- "rw", "delword", "dw", "удслово", # сокращения
+ "rw", "delword", "dw", "удслово",
],
"remlemma": [
"remlemma", "удалитьлемму", # основные
"кутдуььф", "elfkbnmktve", # раскладка
- "rl", "dellemma", "dl", "удлемму", # сокращения
+ "rl", "dellemma", "dl", "удлемму",
],
"rempart": [
"rempart", "удалитьчасть", # основные
"кутзфке", "elfkbnmxfcnm", # раскладка
- "rp", "delpart", "dp", "удчасть", # сокращения
+ "rp", "delpart", "dp", "удчасть",
],
# ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ====================
-
"remtempword": [
"remtempword", "удалитьвремслово", # основные
"кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка
- "rtw", "deltw", "удтемпслово", # сокращения
+ "rtw", "deltw", "удтемпслово", "rtword", "rtempword",
],
"remtemplemma": [
"remtemplemma", "удалитьвремлемму", # основные
"кутеуьздуььф", "elfkbnmdhtvktve", # раскладка
- "rtl", "deltl", "удтемплемму", # сокращения
+ "rtl", "deltl", "удтемплемму", "rtlemma", "rtemplemma", "rtlem",
],
# ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
-
"remexcept": [
"remexcept", "удалитьисключение", # основные
"кутучсузе", "elfkbnmbcrkx", # раскладка
- "rxc", "remwhite", "удискл", # сокращения
+ "rxc", "remwhite", "удискл",
],
# ==================== КОНФЛИКТНЫЕ СЛОВА ====================
-
"addconflictword": [
"addconflictword", "добавитьконфликт", # основные
"фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка
- "acw", "addcw", "конфслово", # сокращения
+ "acw", "addcw", "конфслово", "conflictword",
],
"addconflictlemma": [
"addconflictlemma", "добавитьконфлемму", # основные
"фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка
- "acl", "addcl", "конфлемму", # сокращения
+ "acl", "addcl", "конфлемму", "conflictlemma",
],
"remconflictword": [
"remconflictword", "удалитьконфликт", # основные
"кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка
- "rcw", "delcw", "удконфликт", # сокращения
+ "rcw", "delcw", "удконфликт",
],
"remconflictlemma": [
"remconflictlemma", "удалитьконфлемму", # основные
- "кутсщтakшседуььф", "elfkbnmrjyaktve", # раскладка
- "rcl", "delcl", "удконфлемму", # сокращения
+ "кутсщтакшседуььф", "elfkbnmrjyaktve", # раскладка
+ "rcl", "delcl", "удконфлемму",
],
# ==================== РЕЖИМ АНТИКОНФЛИКТА ====================
-
"stopconflict": [
"stopconflict", "стопконфликт", # основные
- "cnjgsщтakшse", "cnjzrjyakbrn", # раскладка
- "sconf", "sc", "стопконф", # сокращения
+ "cnjgsщтакшse", "cnjzrjyakbrn", # раскладка
+ "sconf", "sc", "стопконф", "stopconf",
],
"unstopconflict": [
"unstopconflict", "отменаконфликта", # основные
- "eycnjgsщтakшse", "jnvtyf", # раскладка
- "usconf", "usc", "откконф", # сокращения
+ "eycnjgsщтакшse", "jnvtyf", # раскладка
+ "usconf", "usc", "откконф", "unstopconf",
],
"conflictstatus": [
"conflictstatus", "статусконфликта", # основные
- "сщтakшseыефnec", "cnfnec", # раскладка
- "cstatus", "cs", "статконф", # сокращения
+ "сщтакшseыефnec", "cnfnec", # раскладка
+ "cstatus", "cs", "статконф", "confstat",
],
# ==================== РЕЖИМ ТИШИНЫ ====================
-
"silence": [
- "silence", "тишина", "мут", # основные
- "ышдутсу", "nbibyf", "ven", # раскладка
- "sil", "mute", "quiet", "тиш", # сокращения
+ "silence", "тишина", # основные
+ "ышдутсу", "nbibyf", # раскладка
+ "sl", "sil", "mute", "quiet", "тиш", "ven",
],
"unsilence": [
"unsilence", "отменатишины", # основные
- "eтышдутсу", "jnvtyf", # раскладка
- "unsil", "unmute", "откмут", # сокращения
+ "eышдутсу", "jnvtyf", # раскладка
+ "unsil", "unmute", "откмут", "usl", "unsl",
],
"silencestatus": [
"silencestatus", "статустишины", # основные
- "ышдутсуыефnec", "cnfnec", # раскладка
- "sstatus", "ss", "статтиш", # сокращения
+ "ышдутсуыефnec", "cnfnec", # раскладка
+ "sstatus", "ss", "статтиш",
],
"extend_silence": [
"extend_silence", "продлитьтишину", # основные
- "уче_ышдутсу", "ghjlkbnmnbibyet", # раскладка
- "exsil", "exs", "продтиш", # сокращения
+ "ex_ышдутсу", "ghjlkbnmnbibyet", # раскладка
+ "exsil", "exs", "продтиш",
],
# ==================== АДМИНИСТРАТОРЫ ====================
-
"addadmin": [
"addadmin", "добавитьадмина", # основные
"фввфвьшт", "lj,fdbnmflvbyf", # раскладка
- "aa", "addadm", "добадм", # сокращения
+ "aa", "addadm", "добадм",
],
"remadmin": [
"remadmin", "удалитьадмина", # основные
"кутфвьшт", "elfkbnmflvbyf", # раскладка
- "ra", "remadm", "deladmin", "удадм", # сокращения
+ "ra", "remadm", "deladmin", "удадм",
],
"listadmins": [
"listadmins", "списокадминов", # основные
"дшыефвьшты", "cgbcjrflvbyjd", # раскладка
- "admins", "adm", "adminlist", "адм", # сокращения
+ "admins", "adm", "adminlist", "адм", "дшыефвь", "listadm", "la",
],
"adminhelp": [
"adminhelp", "помощьадмину", # основные
"фвьштрудз", "gjvjomflvbyt", # раскладка
- "admhelp", "ah", "хелпадм", # сокращения
+ "admhelp", "ah", "хелпадм",
],
"checkadmin": [
"checkadmin", "проверкаадмина", # основные
"сруслфвьшт", "ghjdthrf", # раскладка
- "isadmin", "ca", "провадм", # сокращения
+ "isadmin", "ca", "провадм", "checkadm",
],
# ==================== ПРОСМОТР ====================
-
"list": [
- "listwords", "списокслов", # основные
+ "listwords", "списокслов", "listword", # основные
"дшыецщквы", "cgbcjrckjd", # раскладка
- "lw", "list", "дшые", "words", "слова", # сокращения
+ "lw", "list", "дшые", "words", "слова", "l",
],
"listlemmas": [
"listlemmas", "списоклемм", # основные
"дшыедуььфы", "cgbcjrktv", # раскладка
- "ll", "lemmas", "леммы", # сокращения
+ "ll", "lemmas", "леммы",
],
"listparts": [
"listparts", "списокчастей", # основные
"дшыезфкеы", "cgbcjrxfcntq", # раскладка
- "lp", "parts", "части", # сокращения
+ "lp", "parts", "части",
],
"listexcept": [
"listexcept", "списокисключений", # основные
"дшыеучсузе", "cgbcjrbcrkx", # раскладка
- "lxc", "except", "white", "искл", # сокращения
+ "lxc", "except", "white", "искл",
],
"listconflict": [
"listconflict", "списокконфликтов", # основные
- "дшыесщтakшse", "cgbcjrrjyakbrnjd", # раскладка
- "lc", "conflict", "конф", # сокращения
+ "дшыесщтакшse", "cgbcjrrjyakbrnjd", # раскладка
+ "lc", "conflict", "конф",
],
# ==================== СТАТИСТИКА ====================
-
"userstats": [
"userstats", "статистикапользователя", # основные
"ecthыефnы", "cnfnbcnbrf", # раскладка
- "ustat", "us", "статюзер", # сокращения
+ "ustat", "us", "статюзер",
],
"resetstats": [
"resetstats", "сброситьстат", # основные
"кыуеыефnы", "c,hjcbnm", # раскладка
- "rstats", "clearstats", "сброс", # сокращения
+ "rstats", "clearstats", "сброс",
],
# ==================== ИНФОРМАЦИЯ ====================
-
"id": [
"id", "айди", "инфо", # основные
"шв", "fqlb", "byaj", # раскладка
- "info", "me", "мои", # сокращения
+ "info", "me", "мои",
],
"myid": [
"myid", "мойайди", # основные
"ьншв", "vjqfqlb", # раскладка
- "mid", "мид", # сокращения
+ "mid", "мид",
],
"chatid": [
"chatid", "айдичата", # основные
"срфешв", "fqlbxfnf", # раскладка
- "cid", "чатид", # сокращения
+ "cid", "чатид",
],
# ==================== РЕПОРТЫ ====================
-
"report": [
"report", "репорт", "жалоба", # основные
"кузщке", "htgjhn", ";fkj,f", # раскладка
- "rep", "r", "жал", # сокращения
+ "rep", "r", "жал",
],
"reporthelp": [
"reporthelp", "помощьрепорт", # основные
"кузщкерудз", "gjvjomhtgjhn", # раскладка
- "rephelp", "rh", "хелпреп", # сокращения
+ "rephelp", "rh", "хелпреп",
],
"reportstats": [
"reportstats", "статистикарепортов", # основные
"кузщкеыефnы", "cnfnbcnbrf", # раскладка
- "rstat", "rs", "статреп", # сокращения
+ "rstat", "rs", "статреп",
],
"checkreport": [
"checkreport", "проверкарепорта", # основные
"сруслкузщке", "ghjdthrf", # раскладка
- "crep", "cr", "провреп", # сокращения
+ "crep", "cr", "провреп",
],
"closereport": [
"closereport", "закрытьрепорт", # основные
"сдщыукузщке", "pfrhsnm", # раскладка
- "close", "cl", "закреп", # сокращения
+ "close", "cl", "закреп",
],
"banreport": [
"banreport", "забанитьрепорт", # основные
"фтшкузщке", "pf,fybnm", # раскладка
- "banrep", "br", "банреп", # сокращения
+ "banrep", "br", "банреп",
],
# ==================== ЭМОДЗИ ====================
-
"emoji": [
"emoji", "эмодзи", # основные
"уьщош", "'vjlpb", # раскладка
- "em", "emj", "эм", # сокращения
+ "em", "emj", "эм",
],
"emojihelp": [
"emojihelp", "помощьэмодзи", # основные
"уьщошрудз", "gjvjom'vjlpb", # раскладка
- "emhelp", "emh", "хелпэм", # сокращения
+ "emhelp", "emh", "хелпэм",
],
# ==================== СИСТЕМНЫЕ ====================
-
"ping": [
"ping", "пинг", # основные
"зштп", "gbyp", # раскладка
- "p", "пн", # сокращения
+ "p", "пн",
],
"version": [
"version", "версия", # основные
"дукышщт", "dthcbz", # раскладка
- "ver", "v", "вер", # сокращения
+ "ver", "v",
],
"reload": [
"reload", "перезагрузка", # основные
"кудщфв", "gthtpfuheprf", # раскладка
- "rl", "restart", "рест", # сокращения
+ "rl", "restart", "рест",
],
"logs": [
"logs", "логи", # основные
"дщпы", "kjub", # раскладка
- "log", "l", "лог", # сокращения
+ "log", "l",
+ ],
+
+ "cancel": [
+ "cancel", "c", # основные
+ "отменить", "сфтскд", # раскладка
+ ],
+
+ "redactcomment": [
+ "redactcomment", "editcomment", "комментарии", "redc", # основные + сокращения
+ "кувфсщтскщйьщк", "gfhthfyjdfz", # раскладка
+ "redcom", "editcom", "коммент", "rc", # дополнения
],
-"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
}
diff --git a/configs/config.py b/configs/config.py
index 34384af..77bb8a4 100644
--- a/configs/config.py
+++ b/configs/config.py
@@ -55,7 +55,6 @@ class _Settings(BaseSettings):
# Идентификаторы
OWNER_ID: list[int] = [6751720805]
ADMIN_ID: list[int] = []
- ADMIN_CHAT_ID: int = 0
# Настройки бота
BOT_NAME: str = "Бот"
@@ -89,6 +88,24 @@ class _Settings(BaseSettings):
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
+
# Права администратора
diff --git a/database/manager.py b/database/manager.py
index b2b019b..7c1f09c 100644
--- a/database/manager.py
+++ b/database/manager.py
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from middleware.loggers import logger
from .database import Database, get_db
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
@@ -43,6 +43,7 @@ class BanWordsManager:
async def init(self) -> None:
"""Инициализирует базу данных и загружает кэш"""
await self.db.init()
+ await self.init_default_bot_settings() # ← добавлено
await self.refresh_cache()
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
@@ -335,7 +336,6 @@ class BanWordsManager:
now = datetime.now().timestamp()
if now >= silence_until:
- # Время истекло - удаляем настройку
await self.disable_silence_mode()
return False
@@ -381,7 +381,6 @@ class BanWordsManager:
now = datetime.now().timestamp()
if now >= conflict_until:
- # Время истекло
await self.disable_conflict_mode()
return False
@@ -433,7 +432,6 @@ class BanWordsManager:
"""Получает общую статистику"""
db_stats = await self.repo.get_stats()
- # Добавляем информацию о кэше
cache_info = {
'cache_active': self._cache_banwords is not 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:
try:
- # Группируем по matched_word и считаем количество
query = select(
SpamLog.matched_word,
SpamLog.match_type,
@@ -497,7 +494,6 @@ class BanWordsManager:
result = await session.execute(query)
rows = result.all()
- # Форматируем результат
top_words = []
for row in rows:
top_words.append({
@@ -531,7 +527,6 @@ class BanWordsManager:
try:
now = datetime.now(timezone.utc)
- # Ищем истёкшие временные слова
query = select(TempBanWord).where(
TempBanWord.expires_at < now
)
@@ -541,23 +536,18 @@ class BanWordsManager:
if not expired_words:
return 0
- # Собираем информацию для логирования
expired_info = []
for word in expired_words:
expired_info.append({
'word': word.word,
- 'type': word.word_type.value,
+ 'type': word.type.value, # ← ИСПРАВЛЕНО: было word.word_type.value
'expires_at': word.expires_at
})
await session.delete(word)
- # Сохраняем изменения
await session.commit()
-
- # Обновляем кеш
await self.refresh_cache()
- # Логируем подробности
logger.info(
f"Удалено {len(expired_words)} истёкших временных банвордов",
log_type="DATABASE"
@@ -608,7 +598,6 @@ class BanWordsManager:
"""
async with self.db.get_session() as session:
try:
- # Удаляем все записи
await session.execute(delete(SpamLog))
await session.commit()
@@ -629,32 +618,30 @@ class BanWordsManager:
"""
Получает настройки автокомментариев для канала.
- Args:
- channel_id: ID канала
-
- Returns:
- dict: Настройки или значения по умолчанию
+ ВАЖНО: возвращает сохранённые поля даже когда is_enabled=False,
+ чтобы UI/preview показывали реальную конфигурацию.
"""
from configs import settings
auto_comment = await self.repo.get_auto_comment(channel_id)
- if auto_comment and auto_comment.is_enabled:
- return {
- 'text': auto_comment.text,
- 'button_text': auto_comment.button_text,
- 'button_url': auto_comment.button_url,
- 'photo_url': auto_comment.photo_url,
- 'is_enabled': auto_comment.is_enabled,
- }
-
- # Возвращаем настройки по умолчанию из .env
- return {
+ defaults = {
'text': settings.AUTO_COMMENT_TEXT,
'button_text': settings.AUTO_COMMENT_BUTTON_TEXT,
'button_url': settings.AUTO_COMMENT_BUTTON_URL,
'photo_url': settings.AUTO_COMMENT_PHOTO_URL,
- 'is_enabled': False, # По умолчанию выключено
+ '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(
@@ -715,6 +702,136 @@ class BanWordsManager:
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: # Не null
+ await self.set_bot_setting(key, str(value))
+
+ logger.info("✅ Настройки бота инициализированы из .env", log_type="SETTINGS")
+ except Exception as e:
+ logger.warning(f"Не удалось инициализировать настройки из .env: {e}", log_type="SETTINGS")
+
# Глобальный экземпляр менеджера
_manager_instance: Optional[BanWordsManager] = None
diff --git a/database/models.py b/database/models.py
index 8bf4acc..c56bab3 100644
--- a/database/models.py
+++ b/database/models.py
@@ -19,7 +19,8 @@ __all__ = (
"Setting",
"SpamStat",
"SpamLog",
-"AutoComment",
+ "AutoComment",
+ "Report",
)
@@ -294,3 +295,68 @@ class AutoComment(Base):
def __repr__(self) -> str:
return f""
+
+
+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""
diff --git a/database/repository.py b/database/repository.py
index 425711e..51fa523 100644
--- a/database/repository.py
+++ b/database/repository.py
@@ -157,12 +157,6 @@ class BanWordsRepository:
return set()
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
- """
- Получает все банворды, сгруппированные по типам.
-
- Returns:
- dict: {BanWordType: Set[str]}
- """
result = {
BanWordType.SUBSTRING: set(),
BanWordType.LEMMA: set(),
@@ -170,19 +164,28 @@ class BanWordsRepository:
BanWordType.CONFLICT_SUBSTRING: set(),
BanWordType.CONFLICT_LEMMA: set(),
}
-
try:
async with self.db.get_session() as session:
banwords = await session.execute(select(BanWord))
+ loaded = 0
for banword in banwords.scalars():
- result[banword.type].add(banword.word)
-
+ try:
+ word_type = (
+ banword.type
+ if isinstance(banword.type, BanWordType)
+ 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"
+ )
+ logger.info(f"✅ Кэш загружен: {loaded} банвордов", log_type="DATABASE")
except Exception as e:
- logger.error(
- f"Ошибка получения всех банвордов: {e}",
- log_type="DATABASE"
- )
-
+ logger.error(f"❌ get_all_banwords: {e}", log_type="DATABASE")
return result
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():
- if temp_banword.type in result:
- result[temp_banword.type].add(temp_banword.word)
+ word_type = (
+ 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:
logger.error(
@@ -1046,3 +1055,336 @@ class BanWordsRepository:
log_type="DATABASE"
)
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