Compare commits

..

2 Commits

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

View File

@@ -1,7 +1,9 @@
"""
Автоматическая отправка комментариев под постами канала (через discussion group)
+ меню настройки (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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
"""Создаёт главное меню управления автокомментариями"""
@@ -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=(
" <b>ДОБАВИТЬ КАНАЛ</b>\n\n"
"Отправьте ID канала (число с минусом):\n"
"<code>Пример: -1003876862007</code>\n\n"
"💡 @userinfobot для получения ID\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_add_channel, IsAdmin())
async def process_add_channel(message: Message, state: FSMContext) -> None:
if message.text == "/cancel":
await state.clear()
await message.answer("❌ Отменено")
return
try:
channel_id = int(message.text.strip())
if not str(channel_id).startswith('-'):
raise ValueError()
except ValueError:
await message.answer("❌ Неверный ID. Пример: <code>-1003876862007</code>", parse_mode="HTML")
return
manager = get_manager()
success = await manager.add_auto_comment_channel(channel_id, message.from_user.id)
await state.clear()
if success:
await message.answer(f"✅ <b>Канал добавлен!</b>\n<code>{channel_id}</code>\n/redactcomment", parse_mode="HTML")
else:
await message.answer(f"❌ Канал <code>{channel_id}</code> уже существует!", parse_mode="HTML")
# ======================================================================
# DIAGNOSTICS ✅ ФИКС #2
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic"))
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 = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
# 1) ENV settings
channels = settings.AUTO_COMMENT_CHANNELS_LIST
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await!
diagnostic_text += "1⃣ <b>Настройки:</b>\n"
diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: <code>{channels}</code>\n"
diagnostic_text += f" ├─ Каналы из БД: <code>{channels}</code>\n"
diagnostic_text += f" └─ Канал в списке: {'' if channel_id in channels else ''}\n\n"
# 2) DB config
diagnostic_text += "2⃣ <b>База данных:</b>\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⃣ <b>Бот в канале:</b>\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⃣ <b>Привязанная группа обсуждений:</b>\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⃣ <b>Бот в группе обсуждений:</b>\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 += "💡 <b>Что должно быть для работы:</b>\n"
if channel_id not in channels:
diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n"
diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n"
diagnostic_text += "Подключите discussion group к каналу\n"
diagnostic_text += " • Дайте боту право писать в группе обсуждений\n"
diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n"
await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
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(
" <b>Каналы не настроены</b>\n\n"
"Добавьте ID каналов в .env файл:\n"
"<code>AUTO_COMMENT_CHANNELS=-1003876862007</code>\n\n"
"💡 Узнать ID канала: перешлите пост из канала боту @userinfobot",
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
"🚫 <b>Каналы не настроены</b>\n\n"
"👆 <b> Добавить канал</b>",
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(
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
"Выберите канал для настройки:",
"📢 <b>Выберите канал:</b>",
reply_markup=create_channels_menu(channels).as_markup(),
parse_mode="HTML"
)
async def show_channel_menu(message: Message, channel_id: int) -> None:
"""Показывает меню настроек для конкретного канала"""
config = await get_channel_config(channel_id)
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
text = config.get("text") or ""
photo_url = config.get("photo_url") or ""
text_preview = (text[:100] + "...") if len(text) > 100 else text
photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
output = (
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
await callback.message.edit_text(
text=output,
reply_markup=create_main_menu(channel_id).as_markup(),
parse_mode="HTML"
)
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=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
"Отправьте новый текст комментария.\n\n"
"💡 <b>Поддерживается HTML</b>\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
"Отправьте новый текст комментария.\n\n"
"💡 <b>Поддерживается HTML</b>\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_text)
@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"✅ <b>Текст обновлён!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await message.answer("✅ <b>Текст обновлён!</b>", 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=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
"<b>Шаг 1 из 2:</b> Отправьте текст кнопки\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
"<b>Шаг 1 из 2:</b> Отправьте текст кнопки\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_button_text)
@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"✅ Текст кнопки: <b>{message.text}</b>\n\n"
f"✅ Текст кнопки: <b>{(message.text or '').strip()}</b>\n\n"
f"<b>Шаг 2 из 2:</b> Отправьте 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(
"❌ <b>Неверный формат URL</b>\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("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await show_channel_menu(message, int(channel_id))
# ======================================================================
# EDIT PHOTO URL
# ======================================================================
@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=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
"Отправьте прямую ссылку на изображение (http/https).\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
"Отправьте прямую ссылку на изображение (http/https).\n\n"
"Для отмены: /cancel"
),
parse_mode="HTML"
)
await callback.answer()
@router.message(CommentEditStates.waiting_photo_url)
@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(
"❌ <b>Неверный формат URL</b>\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("<b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
return
await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML")
await show_channel_menu(message, channel_id)
await show_channel_menu(message, int(channel_id))
# ======================================================================
# PREVIEW
# ======================================================================
@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"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
if callback.message:
await callback.message.answer(
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
reply_markup=keyboard.as_markup(),
parse_mode="HTML"
)
await callback.answer("✅ Превью отправлено")
# ======================================================================
# TOGGLE
# ======================================================================
@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"))
@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"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
f"💡 Выберите действие:"
)
await callback.message.edit_text(
text=output,
reply_markup=create_main_menu(channel_id).as_markup(),
parse_mode="HTML"
)
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=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
f"Автокомментарии для канала <code>{channel_id}</code> удалены.\n\n"
"Будут использоваться настройки по умолчанию из .env\n\n"
"Для настройки: /redactcomment"
),
parse_mode="HTML"
)
if callback.message:
await callback.message.edit_text(
text=(
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
f"Автокомментарии для канала <code>{channel_id}</code> удалены.\n\n"
"Будут использоваться настройки по умолчанию из .env\n\n"
"Для настройки: /redactcomment"
),
parse_mode="HTML"
)
# ======================================================================
# CLOSE / CANCEL
# ======================================================================
@router.callback_query(F.data == "menu:close")
async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await callback.message.delete()
await callback.answer("❌ Меню закрыто")
@router.message(Command("cancel"))
async def cancel_handler(message: Message, state: FSMContext) -> None:
current_state = await state.get_state()
if current_state is None:
await message.answer("❌ Нечего отменять")
return
await state.clear()
await message.answer("✅ Действие отменено")

View File

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

View File

@@ -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" Использование: <code>/{command} <ID></code>"
return False, f'{tg_emoji("4961187972822074653")} Использование: <code>/{command} <ID></code>'
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"<code>{user_id}</code> (@{username})"
return f"<code>{user_id}</code>"
return f'<code>{user_id}</code> (@{username})'
return f'<code>{user_id}</code>'
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 <ID>
Пример: /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(
"⚠️ <b>Вы уже владелец бота</b>\n\n"
"Вам не нужно добавлять себя в администраторы",
parse_mode="HTML"
f'{tg_emoji("4963024861615096794")} <b>Вы уже владелец бота</b>\n\n'
'Вам не нужно добавлять себя в администраторы',
parse_mode='HTML'
)
return
# Проверка: нельзя добавить другого владельца
if user_id in settings.OWNER_ID:
await message.answer(
"⚠️ <b>Этот пользователь уже владелец бота</b>\n\n"
"Владельцы имеют полные права автоматически",
parse_mode="HTML"
f'{tg_emoji("4963024861615096794")} <b>Этот пользователь уже владелец бота</b>\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")} Пользователь <b>{display_name}</b> уже является администратором',
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" <b>Администратор добавлен</b>\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"⚠️ <i>Не может управлять другими админами</i>\n"
f"Список админов: /listadmins"
)
logger.info(
f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
log_type="ADMIN_MGMT"
f'{tg_emoji("4963010134172239128")} <b>Администратор добавлен</b>\n\n'
f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
f'{tg_emoji("4963343509533754468")} Имя: <b>{display_name}</b>\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")} <i>Не может управлять другими админами</i>\n'
f'Список админов: <b>/listadmins</b>'
)
logger.info(f'Администратор добавлен: {user_id} (добавил: {message.from_user.id})', log_type='ADMIN_MGMT')
else:
text = " <b>Ошибка добавления администратора</b>\n\nПопробуйте позже"
text = f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления администратора</b>\n\nПопробуйте позже'
await message.answer(text, parse_mode="HTML")
await message.answer(text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT")
await message.answer(" <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
logger.error(f'Ошибка добавления администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления</b>\n\nПопробуйте позже', parse_mode='HTML')
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True),
@router.message(Command(*COMMANDS.get('remadmin', ['remadmin']), prefix=settings.PREFIX, ignore_case=True),
IsSuperAdmin())
@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 <ID>
Пример: /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(
"⚠️ <b>Нельзя удалить владельца</b>\n\n"
"Владельцы имеют права постоянно",
parse_mode="HTML"
f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить владельца</b>\n\n'
'Владельцы имеют права постоянно',
parse_mode='HTML'
)
return
# Проверка: нельзя удалить самого себя (если вы владелец)
if user_id == message.from_user.id:
await message.answer(
"⚠️ <b>Нельзя удалить самого себя</b>",
parse_mode="HTML"
f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить самого себя</b>',
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")} Пользователь <b>{display_name}</b> не является администратором',
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"🗑 <b>Администратор удалён</b>\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"⚠️ <i>Пользователь больше не имеет доступа к командам бота</i>"
)
logger.info(
f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
log_type="ADMIN_MGMT"
f'🗑 <b>Администратор удалён</b>\n\n'
f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
f'{tg_emoji("4961064956368782417")} Имя: <b>{display_name}</b>\n'
f'{tg_emoji("4963343509533754468")} Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
f'{tg_emoji("4963024861615096794")} <i>Пользователь больше не имеет доступа к командам бота</i>'
)
logger.info(f'Администратор удалён: {user_id} (удалил: {message.from_user.id})', log_type='ADMIN_MGMT')
else:
text = " <b>Ошибка удаления администратора</b>\n\nПопробуйте позже"
text = f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления администратора</b>\n\nПопробуйте позже'
await message.answer(text, parse_mode="HTML")
await message.answer(text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT")
await message.answer(" <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
logger.error(f'Ошибка удаления администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления</b>\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")} <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n'
# === ФОРМИРУЕМ ВЫВОД ===
output = "👥 <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n"
# Владельцы (OWNER_ID)
output += "👑 <b>Владельцы бота</b> (полные права):\n"
# ВЛАДЕЛЬЦЫ
output += f'{tg_emoji("4963343509533754468")} <b>Владельцы бота</b> (полные права):\n'
for owner_id in settings.OWNER_ID:
output += f'├─ <a href="tg://user?id={owner_id}">{owner_id}</a>\n'
output += "\n"
display_name = await get_user_display_name(owner_id)
output += f'├─ <a href="tg://user?id={owner_id}">{display_name}</a>\n'
output += '\n'
# Администраторы из БД
# АДМИНИСТРАТОРЫ
if db_admins:
output += f"⚙️ <b>Администраторы</b> ({len(db_admins)}):\n"
output += f'{tg_emoji("4961064956368782417")} <b>Администраторы</b> ({len(db_admins)}):\n'
for admin_id in sorted(db_admins):
output += f'├─ <a href="tg://user?id={admin_id}">{admin_id}</a>\n'
output += "\n"
output += "📋 <b>Права администраторов:</b>\n"
output += "├─ Управление банвордами\n"
output += "├─ Просмотр статистики\n"
output += "├─ Активация режимов модерации\n"
output += "└─ Все команды бота (кроме управления админами)\n\n"
display_name = await get_user_display_name(admin_id)
output += f'├─ <a href="tg://user?id={admin_id}">{display_name}</a>\n'
output += '\n'
output += f'{tg_emoji("4961106084975608869")} <b>Права администраторов:</b>\n'
output += '├─ Управление банвордами\n'
output += '├─ Просмотр статистики\n'
output += '├─ Активация режимов модерации\n'
output += '└─ Все команды бота\n\n'
else:
output += "⚙️ <b>Администраторы:</b>\n"
output += "└─ <i>Нет дополнительных администраторов</i>\n\n"
output += f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
output += '└─ <i>Нет дополнительных администраторов</i>\n\n'
# Общая статистика
total_admins = len(settings.OWNER_ID) + len(db_admins)
output += f"📊 <b>Итого:</b> {total_admins} администратор(ов)\n\n"
output += f'{tg_emoji("4961061266991875258")} <b>Итого:</b> {total_admins} администратор(ов)\n\n'
# Команды управления
output += "🔧 <b>Управление:</b>\n"
output += "/addadmin <code>ID</code> — добавить админа\n"
output += "/remadmin <code>ID</code> — удалить админа\n\n"
output += f'{tg_emoji("4961027057577362562")} <b>Управление:</b>\n'
output += '• <b>/adminhelp</b> — помощь по командам админов\n'
output += '• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
output += '• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
output += f'{tg_emoji("4961186405159011104")} <i>Только владельцы могут управлять администраторами</i>'
output += "💡 <i>Только владельцы могут управлять администраторами</i>"
# Клавиатура
keyboard = get_refresh_admins_kb()
# Отправка
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 = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
logger.error(f'Ошибка получения списка администраторов: {e}', log_type='ADMIN_MGMT')
error_text = f'{tg_emoji("4961187972822074653")} <b>Ошибка загрузки списка</b>\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 = (
" <b>Как добавить администратора?</b>\n\n"
"1 Узнайте Telegram ID пользователя\n"
" • Используйте бота @userinfobot\n"
" • Или попросите пользователя написать /start\n\n"
"2⃣ Выполните команду:\n"
" <code>/addadmin ID</code>\n\n"
"Пример:\n"
"<code>/addadmin 123456789</code>"
f'{tg_emoji("4963469772982322370")} <b>Как добавить администратора?</b>\n\n'
f'{tg_emoji("4960889107522782272")} Узнайте Telegram ID пользователя\n'
' • Используйте команду <b>/id</b> или бота @userinfobot\n'
f'{tg_emoji("4960889107522782272")} Выполните команду:\n'
' <code>/addadmin ID</code>\n\n'
'Пример:\n'
'<code>/addadmin 123456789</code>'
)
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 = (
"👥 <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n"
"🔐 <b>Уровни доступа:</b>\n\n"
"👑 <b>Владельцы</b> (OWNER_ID):\n"
"├─ Все права администратора\n"
"├─ Управление другими админами\n"
"└─ Указываются в конфигурации\n\n"
"⚙️ <b>Администраторы:</b>\n"
"├─ Управление банвордами\n"
"├─ Просмотр статистики\n"
"├─ Активация режимов модерации\n"
"└─ НЕ могут управлять админами\n\n"
"📝 <b>Команды:</b>\n"
"• /listadmins — список всех админов\n"
"• /addadmin <code>ID</code> — добавить админа\n"
"• /remadmin <code>ID</code> — удалить админа\n\n"
"💡 <b>Как узнать ID пользователя?</b>\n"
"• Используйте бота @userinfobot\n"
"• Попросите пользователя написать боту\n"
"• ID отображается в логах бота\n\n"
"⚠️ <b>Важно:</b>\n"
"├─ Нельзя удалить владельца\n"
"├─ Нельзя удалить самого себя\n"
"└─ Все действия логируются"
f'{tg_emoji("4960891456869893259")} <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n'
f'{tg_emoji("4963401727815451692")} <b>Уровни доступа:</b>\n\n'
f'{tg_emoji("4963343509533754468")} <b>Владельцы</b> (OWNER_ID):\n'
'├─ Все права администратора\n'
'├─ Управление другими админами\n'
'└─ Указываются в конфигурации\n\n'
f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
'├─ Управление банвордами\n'
'├─ Просмотр статистики\n'
'├─ Активация режимов модерации\n'
'└─ Не могут управлять админами\n\n'
f'{tg_emoji("4963241130398319816")} <b>Команды:</b>\n'
'• <b>/adminhelp</b> — помощь по командам админов\n'
'• <b>/listadmins</b> — список всех админов\n'
'• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
'• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
f'{tg_emoji("4961186405159011104")} <b>Как узнать ID пользователя?</b>\n'
'• Используйте команду <b>/id</b> или бота @userinfobot\n'
'• Или попросите пользователя написать боту\n'
'• ID отображается в логах бота\n\n'
f'{tg_emoji("4963024861615096794")} <b>Важно:</b>\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 <ID>
"""
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"🔍 <b>Проверка пользователя</b>\n\n"
text += f"👤 ID: <code>{user_id}</code>\n\n"
text = f'{tg_emoji("4961092195051373778")} <b>Проверка пользователя</b>\n\n'
text += f'{tg_emoji("4961064956368782417")} ID: <code>{user_id}</code>\n\n'
if is_owner:
text += "👑 Статус: <b>Владелец бота</b>\n"
text += " Полные права администратора\n"
text += " Может управлять админами"
text += f'{tg_emoji("4963343509533754468")} Статус: <b>Владелец бота</b>\n'
text += f'{tg_emoji("4963010134172239128")} Полные права администратора\n'
text += f'{tg_emoji("4963010134172239128")} Может управлять админами'
elif is_db_admin:
text += "⚙️ Статус: <b>Администратор</b>\n"
text += " Доступ к командам бота\n"
text += " Не может управлять админами"
text += f'{tg_emoji("4961064956368782417")} Статус: <b>Администратор</b>\n'
text += f'{tg_emoji("4963010134172239128")} Доступ к командам бота\n'
text += f'{tg_emoji("4961187972822074653")} Не может управлять админами'
else:
text += "👤 Статус: <b>Обычный пользователь</b>\n"
text += " Нет прав администратора\n\n"
text += f"Добавить в админы: <code>/addadmin {user_id}</code>"
text += f'{tg_emoji("4961064956368782417")} Статус: <b>Обычный пользователь</b>\n'
text += f'{tg_emoji("4961187972822074653")} Нет прав администратора\n\n'
text += f'Добавить в админы: <code>/addadmin {user_id}</code>'
await message.answer(text, parse_mode="HTML")
await message.answer(text, parse_mode='HTML')
except Exception as e:
logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT")
await message.answer(" <b>Ошибка проверки</b>", parse_mode="HTML")
logger.error(f'Ошибка проверки администратора: {e}', log_type='ADMIN_MGMT')
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка проверки</b>', parse_mode='HTML')

View File

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

View File

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

View File

@@ -14,23 +14,28 @@ __all__ = ("router",)
router: Router = Router(name="emoji_extractor_router")
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'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
def escape_html(text: str) -> str:
"""Экранирует HTML символы"""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _build_emoji_block(idx: int, emoji_data: dict, is_last: bool) -> str:
"""Формирует текстовый блок для одного эмодзи."""
emoji_char = emoji_data["char"]
emoji_id = emoji_data["id"]
html_code = format_emoji_html(emoji_char, emoji_id)
html_escaped = escape_html(html_code)
block = (
f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
f"📝 <b>HTML-код:</b>\n"
f"<code>{html_escaped}</code>\n\n"
f"🎨 <b>Превью:</b> {html_code}\n"
)
if not is_last:
block += SEPARATOR
return block
def build_pages(custom_emojis: list[dict]) -> list[str]:
"""
Разбивает список эмодзи на страницы, каждая не длиннее MAX_MSG_LEN.
Возвращает список готовых HTML-строк для отправки.
"""
total = len(custom_emojis)
pages: list[str] = []
current_page = ""
for idx, emoji_data in enumerate(custom_emojis, 1):
is_last = (idx == total)
block = _build_emoji_block(idx, emoji_data, is_last)
if current_page and len(current_page) + len(block) > MAX_MSG_LEN:
pages.append(current_page)
current_page = block
else:
current_page += block
if current_page:
pages.append(current_page)
return pages
# ================= КОМАНДА /EMOJI =================
@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(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
@@ -98,66 +127,57 @@ async def emoji_extractor_cmd(message: Message) -> None:
"1. Ответьте на сообщение с премиум эмодзи\n"
"2. Напишите <code>/emoji</code>\n\n"
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
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(
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
"В этом сообщении нет премиум эмодзи.\n\n"
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
parse_mode="HTML"
parse_mode="HTML",
)
return
# === ФОРМИРУЕМ ОТВЕТ ===
total = len(custom_emojis)
pages = build_pages(custom_emojis)
total_pages = len(pages)
output = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}</b>\n\n"
for idx, emoji_data in enumerate(custom_emojis, 1):
emoji_char = emoji_data["char"]
emoji_id = emoji_data["id"]
output += f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
output += f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
# HTML-код (экранированный для отображения)
html_code = format_emoji_html(emoji_char, emoji_id)
html_escaped = escape_html(html_code)
output += f"📝 <b>HTML-код:</b>\n"
output += f"<code>{html_escaped}</code>\n\n"
# Пример использования
output += f"🎨 <b>Превью:</b> {html_code}\n"
if idx < len(custom_emojis):
output += "\n" + "" * 30 + "\n\n"
output += "💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
# Создаём клавиатуру
ikb = InlineKeyboardBuilder()
ikb.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"✨ <b>НАЙДЕНО ЭМОДЗИ: {total}</b>\n\n"
else:
header = f"✨ <b>ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})</b>\n\n"
# Подвал только на последней странице
footer = (
"\n\n💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
if page_num == total_pages
else ""
)
# Кнопка закрытия только на последней странице
markup = ikb.as_markup() if page_num == total_pages else None
await message.answer(
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(
"❌ <b>Ошибка извлечения эмодзи</b>\n\n"
"Попробуйте позже или обратитесь к разработчику.",
parse_mode="HTML"
parse_mode="HTML",
)
@@ -173,7 +193,6 @@ async def emoji_extractor_cmd(message: Message) -> None:
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
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 = (
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
"📝 <b>Команда /emoji</b>\n"
@@ -201,15 +217,11 @@ async def emoji_help_cmd(message: Message) -> None:
"2⃣ Напишите <code>/emoji</code>\n"
"3⃣ Скопируйте HTML-код\n\n"
"💻 <b>Формат HTML-кода:</b>\n"
"<code>&lt;tg-emoji emoji-id=\"ID\"&gt;fallback&lt;/tg-emoji&gt;</code>\n\n"
"📌 <b>Пример использования в коде:</b>\n"
"<code>text = 'Привет &lt;tg-emoji emoji-id=\"5368324170671202286\"&gt;👍&lt;/tg-emoji&gt;'\n"
"await message.answer(text, parse_mode=\"HTML\")</code>\n\n"
'<code>&lt;tg-emoji emoji-id="ID"&gt;fallback&lt;/tg-emoji&gt;</code>\n\n'
"⚠️ <b>Важно:</b>\n"
"├─ Используйте <code>parse_mode=\"HTML\"</code>\n"
'├─ Используйте <code>parse_mode="HTML"</code>\n'
"├─ Пользователи без Premium видят fallback\n"
"└─ Работает только с кастомными эмодзи\n\n"
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
)
await message.answer(text, parse_mode="HTML")

View File

@@ -9,9 +9,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from configs import settings, COMMANDS
from 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 = "👤 <b>ИНФОРМАЦИЯ О ВАС</b>\n\n"
output = '<tg-emoji emoji-id="4961064956368782417">💠</tg-emoji> <b>ИНФОРМАЦИЯ О ВАС</b>\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"📝 <b>Имя:</b> {full_name}\n"
full_name = ' '.join(full_name_parts) if full_name_parts else 'Не указано'
output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Имя:</b> {full_name}\n'
# Username
if user.username:
output += f"🔗 <b>Username:</b> @{user.username}\n"
output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{user.username}\n'
else:
output += f"🔗 <b>Username:</b> <i>не установлен</i>\n"
output += '<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> <i>не установлен</i>\n'
# ID
output += f"🆔 <b>ID:</b> <code>{user.id}</code>\n\n"
output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>ID:</b> <code>{user.id}</code>\n\n'
# Тип аккаунта
if user.is_bot:
output += f"🤖 <b>Тип:</b> Бот\n"
elif user.is_premium:
output += f"⭐️ <b>Тип:</b> Premium пользователь\n"
output += '🤖 <b>Тип:</b> Бот\n'
elif getattr(user, 'is_premium', False):
output += '<tg-emoji emoji-id="4961075019477156700">💠</tg-emoji> <b>Тип:</b> Premium пользователь\n'
else:
output += f"👥 <b>Тип:</b> Обычный пользователь\n"
output += '👥 <b>Тип:</b> Обычный пользователь\n'
# Дополнительная информация
output += "\n📊 <b>Дополнительно:</b>\n"
output += '\n<tg-emoji emoji-id="4961141003059725568">💠</tg-emoji> <b>Дополнительно:</b>\n'
# Язык
if user.language_code:
@@ -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: <code>{message.chat.id}</code>\n"
chat_type = chat_types.get(message.chat.type, '💬 Чат')
output += f'├─ Чат: {chat_type}\n'
output += f'├─ Название: {chat_title}\n'
output += f'├─ Chat ID: <code>{message.chat.id}</code>\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: <code>{message.message_id}</code>\n\n"
# Подсказка
output += "💡 <i>Эту информацию видите только вы</i>"
output += f'└─ Message ID: <code>{message.message_id}</code>\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: <code>{user.id}</code>"
text = f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> Ваш ID: <code>{user.id}</code>'
if user.username:
text += f"\n🔗 Username: @{user.username}"
text += f'\n<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> Username: @{user.username}'
await message.answer(text, parse_mode="HTML")
await message.answer(text, parse_mode='HTML')
# ================= КОМАНДА /CHATID =================
@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 = "💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n"
output = '💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\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"📝 <b>Тип:</b> {chat_type}\n"
output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Тип:</b> {chat_type}\n'
if chat.title:
output += f"📌 <b>Название:</b> {chat.title}\n"
output += f'📌 <b>Название:</b> {chat.title}\n'
if chat.username:
output += f"🔗 <b>Username:</b> @{chat.username}\n"
output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{chat.username}\n'
output += f"🆔 <b>Chat ID:</b> <code>{chat.id}</code>\n"
output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>Chat ID:</b> <code>{chat.id}</code>\n'
# Дополнительная информация для групп
if chat.type in ["group", "supergroup"]:
if chat.type in ['group', 'supergroup']:
try:
member_count = await message.bot.get_chat_member_count(chat.id)
output += f"👥 <b>Участников:</b> {member_count}\n"
output += f'👥 <b>Участников:</b> {member_count}\n'
except Exception as e:
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
)

View File

@@ -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 += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
output += "<i>(работают только в режиме /stopconflict)</i>\n\n"
output += "<i>(работают только в режиме <code>/stopconflict</code> <code>время</code>)</i>\n\n"
if conflict_words:
output += f"📝 <b>Конфликтные слова</b> ({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 = "❌ <b>Ошибка загрузки списка</b>\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")

View File

@@ -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(
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
@@ -133,7 +112,7 @@ async def report_cmd(message: Message) -> None:
"1. Ответьте на сообщение нарушителя\n"
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
"Пример: <code>/report спам</code>",
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("❌ <b>Ошибка получения данных пользователя</b>", parse_mode="HTML")
await message.answer(
"❌ <b>Ошибка получения данных пользователя</b>",
parse_mode="HTML",
)
return
# Нельзя пожаловаться на самого себя
if reported_user.id == reporter.id:
await message.answer(
"⚠️ <b>Нельзя пожаловаться на самого себя</b>",
parse_mode="HTML"
parse_mode="HTML",
)
return
# Нельзя пожаловаться на бота
if reported_user.is_bot:
await message.answer(
"⚠️ <b>Нельзя пожаловаться на бота</b>",
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(
"⚠️ <b>Нельзя пожаловаться на администратора</b>",
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 = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
# Информация о жалобщике
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
# Информация о нарушителе
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
# Информация о чате
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
report_text += f"💬 <b>Чат:</b> {chat_title}\n"
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n\n"
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
if original_message_thread_id:
report_text += f"📌 <b>Topic ID:</b> <code>{original_message_thread_id}</code>\n"
report_text += "\n"
# Причина
report_text += f"📝 <b>Причина:</b> {reason}\n\n"
report_text += "📄 <b>Текст сообщения:</b>\n"
# Текст сообщения
report_text += f"📄 <b>Текст сообщения:</b>\n"
message_content = None
if reported_message.text:
truncated_text = truncate_text(reported_message.text, max_length=300)
report_text += f"<code>{truncated_text}</code>\n\n"
message_content = reported_message.text
elif reported_message.caption:
truncated_caption = truncate_text(reported_message.caption, max_length=300)
report_text += f"<code>{truncated_caption}</code>\n\n"
message_content = reported_message.caption
else:
content_type = reported_message.content_type
report_text += f"<i>[{content_type}]</i>\n\n"
report_text += f"<i>[{reported_message.content_type}]</i>\n\n"
# Время
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
report_text += f"💡 <i>ID репорта: {report_id}</i>"
# Клавиатура
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(
"✅ <b>Жалоба отправлена администраторам</b>\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(
"❌ <b>Ошибка отправки жалобы</b>\n\nПопробуйте позже или обратитесь к администратору напрямую.",
parse_mode="HTML"
"❌ <b>Ошибка отправки жалобы</b>\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✅ <b>Пользователь забанен</b> ({admin_name})"
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n✅ <b>Пользователь забанен</b> ({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🗑 <b>Сообщение удалено</b> ({admin_name})"
# Обновляем сообщение
updated_text = callback.message.text + f"\n\n🗑 <b>Сообщение удалено</b> ({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✅ <b>Репорт закрыт</b> ({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 = (
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\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 = (
"📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
"⚠️ <i>Функция в разработке</i>\n\n"
"Планируется:\n"
"Всего репортов за всё время\n"
"• Топ жалобщиков\n"
"• Топ нарушителей\n"
"• Распределение по причинам\n"
"• Статистика обработки\n\n"
"💡 <i>Для реализации нужно добавить таблицу reports в БД</i>"
)
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("❌ <b>Ошибка получения статистики</b>", parse_mode="HTML")
return
text = "📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
text += "📈 <b>Общая статистика:</b>\n"
text += f"├─ Всего репортов: <b>{stats.get('total', 0)}</b>\n"
text += f"├─ В ожидании: <b>{stats.get('pending', 0)}</b>\n"
text += f"├─ Закрыто: <b>{stats.get('closed', 0)}</b>\n"
text += f"├─ Забанено: <b>{stats.get('banned', 0)}</b>\n"
text += f"└─ Удалено: <b>{stats.get('deleted', 0)}</b>\n\n"
if top_reporters:
text += "👥 <b>Топ жалобщиков:</b>\n"
for i, (user_id, username, count) in enumerate(top_reporters, 1):
username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}")
text += f"{i}. {username_display} — <b>{count}</b> реп.\n"
text += "\n"
if top_reported:
text += "⚠️ <b>Топ нарушителей:</b>\n"
for i, (user_id, username, count) in enumerate(top_reported, 1):
username_display = f"@{username}" if username and not username.startswith("id") else (username or f"id{user_id}")
text += f"{i}. {username_display} — <b>{count}</b> жалоб\n"
text += "\n"
text += f"🕐 <b>Обновлено:</b> {format_datetime(datetime.now())}"
await message.answer(text, parse_mode="HTML")

View File

@@ -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 = (
"🤖 <b>PrimoGuard - Бот-модератор</b>\n\n"
"Автоматическое удаление сообщений с запрещёнными словами.\n"
"Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
f'{tg_emoji(4961073056677103064)} <b>PrimoGuard - Бот-модератор</b>\n\n'
'<blockquote>Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.</blockquote>\n\n'
)
# === Команды просмотра ===
help_text += (
"📋 <b>Просмотр:</b>\n"
"/list — список всех правил и слов\n"
"/stats — статистика по удалениям\n"
"/id — получение айди пользователя\n"
"/chatid — получение айди чата\n\n"
f'{tg_emoji(4961141003059725568)} <b>Просмотр:</b>\n'
'<b>/list</b> — список всех правил и слов\n'
'<b>/stats</b> — статистика по удалениям\n'
'<b>/id</b> — получение айди пользователя\n'
'<b>/chatid</b> — получение айди чата\n\n'
)
# === Постоянные банворды ===
help_text += (
" <b>Добавить банворд (постоянно):</b>\n"
"/addword <code>слово</code> — подстрока (простой поиск)\n"
"/addlemma <code>слово</code> — лемма (все формы слова)\n"
"/addpart <code>комбинация</code> — часть (поиск без пробелов)\n\n"
f'{tg_emoji(4961019408240608234)} <b>Добавить банворд (постоянно):</b>\n'
'<code>/addword</code> <code>слово</code> — подстрока (простой поиск)\n'
'<code>/addlemma</code> <code>слово</code> — лемма (все формы слова)\n'
'<code>/addpart</code> <code>комбинация</code> — часть (поиск без пробелов)\n\n'
)
# === Временные банворды ===
help_text += (
" <b>Добавить банворд (временно):</b>\n"
"/addtempword <code>слово минуты</code> — временная подстрока\n"
"/addtemplemma <code>слово минуты</code> — временная лемма\n"
"<i>Пример: /addtempword спам 60</i>\n\n"
f'{tg_emoji(4960719190026618714)} <b>Добавить банворд (временно):</b>\n'
'<code>/addtempword</code> <code>слово минуты</code> — временная подстрока\n'
'<code>/addtemplemma</code> <code>слово минуты</code> — временная лемма\n'
'<i>Пример: /addtempword спам 60</i>\n\n'
)
# === Исключения (whitelist) ===
help_text += (
" <b>Исключения (whitelist):</b>\n"
"/addexcept <code>текст</code> — добавить исключение\n"
"/remexcept <code>текст</code> — удалить исключение\n"
"<i>Исключения не проверяются фильтром</i>\n\n"
f'{tg_emoji(4963010134172239128)} <b>Исключения (whitelist):</b>\n'
'<code>/addexcept</code> <code>текст</code> — добавить исключение\n'
'<code>/remexcept</code> <code>текст</code> — удалить исключение\n'
'<i>Исключения не проверяются фильтром</i>\n\n'
)
# === Режимы модерации ===
help_text += (
"🔇 <b>Режим тишины:</b>\n"
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n"
"/unsilence — отключить режим тишины\n"
"/report — отправить репорт\n\n"
f'{tg_emoji(4960987543878239236)} <b>Режим тишины:</b>\n'
'<code>/silence</code> <code>минуты</code> — удалять ВСЕ сообщения\n'
'<b>/unsilence</b> — отключить режим тишины\n'
'<code>/report</code> — отправить репорт\n\n'
)
help_text += (
"⚔️ <b>Режим антиконфликта:</b>\n"
"/addconflictword <code>слово</code> — добавить конфликтное слово\n"
"/addconflictlemma <code>слово</code> — добавить конфликтную лемму\n"
"/stopconflict <code>минуты</code> — активировать режим\n"
"/unstopconflict — отключить режим\n\n"
f'{tg_emoji(4960986152308835400)} <b>Режим антиконфликта:</b>\n'
'<code>/addconflictword</code> <code>слово</code> — добавить конфликтное слово\n'
'<code>/addconflictlemma</code> <code>слово</code> — добавить конфликтную лемму\n'
'<code>/stopconflict</code> <code>минуты</code> — активировать режим\n'
'<code>/unstopconflict</code> — отключить режим\n\n'
)
# === Удаление ===
help_text += (
" <b>Удалить:</b>\n"
"/remword <code>слово</code> — удалить подстроку\n"
"/remlemma <code>слово</code> — удалить лемму\n"
"/rempart <code>комбинация</code> — удалить часть\n"
"/remtempword <code>слово</code> — удалить временную подстроку\n"
"/remtemplemma <code>слово</code> — удалить временную лемму\n"
"/remconflictword <code>слово</code> — удалить конфликтное слово\n"
"/remconflictlemma <code>слово</code> — удалить конфликтную лемму\n\n"
f'{tg_emoji(4961196485447254983)} <b>Удалить:</b>\n'
'<code>/remword</code> <code>слово</code> — удалить подстроку\n'
'<code>/remlemma</code> <code>слово</code> — удалить лемму\n'
'<code>/rempart</code> <code>комбинация</code> — удалить часть\n'
'<code>/remtempword</code> <code>слово</code> — удалить временную подстроку\n'
'<code>/remtemplemma</code> <code>слово</code> — удалить временную лемму\n'
'<code>/remconflictword</code> <code>слово</code> — удалить конфликтное слово\n'
'<code>/remconflictlemma</code> <code>слово</code> — удалить конфликтную лемму\n\n'
)
# === Управление админами (только для суперадминов) ===
if is_super_admin:
help_text += (
"👑 <b>Управление админами (только для владельцев):</b>\n"
"/addadmin <code>ID</code> — добавить администратора\n"
"/remadmin <code>ID</code> — удалить администратора\n"
"/listadmins — список всех админов\n\n"
f'{tg_emoji(4960891456869893259)} <b>Управление админами (только для владельцев):</b>\n'
'<code>/addadmin</code> <i>ID</i> — добавить администратора\n'
'<code>/remadmin</code> <i>ID</i> — удалить администратора\n'
'<b>/redactcomment</b> — изменить комментарий под постом\n'
'<b>/listadmins</b> — список всех админов\n\n'
)
# === Типы проверок ===
help_text += (
" <b>Типы проверок:</b>\n"
"• <b>Подстрока</b> — простой поиск в тексте\n"
"• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n"
"• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n"
"• <b>Временные</b> — автоматически удаляются через N минут\n"
"• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n"
)
help_text += (
"🔧 <b>Технологии:</b>\n"
"• Unicode-нормализация (латиница→кириллица)\n"
"• Обход через разделители (\"с п а м\"\"спам\")\n"
"• Морфологический анализ (pymorphy3)\n"
"• SQLAlchemy + SQLite с кэшированием\n\n"
"💾 Все настройки сохраняются в базе данных"
f'{tg_emoji(4961021096162755737)} <b>Типы проверок:</b>\n'
'• <b>Подстрока</b> — простой поиск в тексте\n'
'• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n'
'• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n'
'• <b>Временные</b> — автоматически удаляются через N минут\n'
'• <b>Конфликтные</b> — работают только в режиме /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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
@@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware):
)
try:
await event.answer(warning_message, parse_mode="HTML")
except:
except Exception:
pass
return None

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ class _Settings(BaseSettings):
# Параметры сообщений
PARSE_MODE: str = "HTML"
PREFIX: str = "/!.&?"
LOG_LEVEL: str = "TRACE"
# Разрешения и логирование
BOT_EDIT: bool = False
@@ -55,7 +56,6 @@ class _Settings(BaseSettings):
# Идентификаторы
OWNER_ID: list[int] = [6751720805]
ADMIN_ID: list[int] = []
ADMIN_CHAT_ID: int = 0
# Настройки бота
BOT_NAME: str = "Бот"
@@ -89,6 +89,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
# Права администратора

View File

@@ -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,141 @@ 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 is not None: # В .env значение задано
existing = await self.get_bot_setting(key)
if existing is None:
await self.set_bot_setting(key, str(value))
logger.debug(f"Установлена настройка {key} из .env", log_type="SETTINGS")
else:
logger.debug(f"Настройка {key} уже существует ({existing}), пропускаем", log_type="SETTINGS")
logger.info("✅ Настройки бота инициализированы из .env (существующие сохранены)", log_type="SETTINGS")
except Exception as e:
logger.warning(f"Не удалось инициализировать настройки из .env: {e}", log_type="SETTINGS")
# Глобальный экземпляр менеджера
_manager_instance: Optional[BanWordsManager] = None

View File

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

View File

@@ -157,12 +157,6 @@ class BanWordsRepository:
return set()
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

View File

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