Compare commits
2 Commits
5d350d0885
...
4d1b8911b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1b8911b3 | |||
| 5aca4e8438 |
@@ -1,7 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Автоматическая отправка комментариев под постами канала (через discussion group)
|
Автоматическая отправка комментариев под постами канала (через discussion group)
|
||||||
|
|
||||||
+ меню настройки (FSM)
|
+ меню настройки (FSM)
|
||||||
+ полная диагностика
|
+ полная диагностика
|
||||||
|
+ ДИНАМИЧЕСКИЕ КАНАЛЫ ИЗ БД (без .env!)
|
||||||
|
|
||||||
ВАЖНО:
|
ВАЖНО:
|
||||||
- Комментарии в Telegram — это reply в привязанной группе обсуждений.
|
- Комментарии в Telegram — это reply в привязанной группе обсуждений.
|
||||||
@@ -11,7 +13,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Tuple, Dict
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
|
|
||||||
from aiogram import Router, F, Bot
|
from aiogram import Router, F, Bot
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
@@ -23,10 +25,11 @@ from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
|||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
from configs import settings
|
from configs import settings, COMMANDS
|
||||||
from database import get_manager
|
from database import get_manager, AutoComment
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
from bot.filters.admin import IsAdmin
|
from bot.filters.admin import IsAdmin
|
||||||
|
from bot.utils import log_action, tg_emoji
|
||||||
|
|
||||||
__all__ = ("router",)
|
__all__ = ("router",)
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ class CommentEditStates(StatesGroup):
|
|||||||
waiting_button_text = State()
|
waiting_button_text = State()
|
||||||
waiting_button_url = State()
|
waiting_button_url = State()
|
||||||
waiting_photo_url = State()
|
waiting_photo_url = State()
|
||||||
|
waiting_add_channel = State() # ✅ ДОБАВИЛИ
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# HELPERS
|
# HELPERS
|
||||||
@@ -58,25 +61,24 @@ def _defaults() -> dict:
|
|||||||
"is_enabled": False,
|
"is_enabled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _render_menu_text(channel_id: int, config: dict) -> str:
|
||||||
|
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
|
||||||
|
|
||||||
async def get_channel_config(channel_id: int) -> dict:
|
text = config.get("text") or ""
|
||||||
"""
|
photo_url = config.get("photo_url") or ""
|
||||||
Получает настройки автокомментариев для канала из БД.
|
text_preview = (text[:100] + "...") if len(text) > 100 else text
|
||||||
Ничего "не затирает": если поля отсутствуют — подставляет дефолты.
|
photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
|
||||||
"""
|
|
||||||
manager = get_manager()
|
|
||||||
config = await manager.get_auto_comment_settings(channel_id) or {}
|
|
||||||
|
|
||||||
merged = _defaults()
|
|
||||||
merged.update({k: v for k, v in config.items() if v is not None})
|
|
||||||
|
|
||||||
# Если в БД is_enabled=False, пользовательские поля (текст/кнопка/фото) сохраняем
|
|
||||||
# и просто считаем фичу выключенной.
|
|
||||||
if "is_enabled" not in config:
|
|
||||||
merged["is_enabled"] = False
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
||||||
|
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
|
||||||
|
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
|
||||||
|
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
|
||||||
|
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
|
||||||
|
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
|
||||||
|
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
|
||||||
|
f"💡 Выберите действие:"
|
||||||
|
)
|
||||||
|
|
||||||
def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
|
def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
|
||||||
"""Создаёт главное меню управления автокомментариями"""
|
"""Создаёт главное меню управления автокомментариями"""
|
||||||
@@ -87,57 +89,50 @@ def create_main_menu(channel_id: int) -> InlineKeyboardBuilder:
|
|||||||
ikb.button(text="👁 Предпросмотр", callback_data=f"edit:{channel_id}:preview")
|
ikb.button(text="👁 Предпросмотр", callback_data=f"edit:{channel_id}:preview")
|
||||||
ikb.button(text="🔄 Переключить", callback_data=f"edit:{channel_id}:toggle")
|
ikb.button(text="🔄 Переключить", callback_data=f"edit:{channel_id}:toggle")
|
||||||
ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic")
|
ikb.button(text="🔍 Диагностика", callback_data=f"edit:{channel_id}:diagnostic")
|
||||||
ikb.button(text="🗑 Удалить настройки", callback_data=f"edit:{channel_id}:delete")
|
ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ
|
||||||
|
ikb.button(text="🗑 Удалить", callback_data=f"edit:{channel_id}:delete")
|
||||||
ikb.button(text="❌ Закрыть", callback_data="menu:close")
|
ikb.button(text="❌ Закрыть", callback_data="menu:close")
|
||||||
ikb.adjust(2, 2, 2, 1, 1)
|
ikb.adjust(2, 2, 2, 2, 1)
|
||||||
return ikb
|
return ikb
|
||||||
|
|
||||||
|
def create_channels_menu(channels: List[int]) -> InlineKeyboardBuilder(): # ✅ List[int]
|
||||||
def create_channels_menu(channels: list[int]) -> InlineKeyboardBuilder:
|
|
||||||
"""Создаёт меню выбора канала"""
|
"""Создаёт меню выбора канала"""
|
||||||
ikb = InlineKeyboardBuilder()
|
ikb = InlineKeyboardBuilder()
|
||||||
for channel_id in channels:
|
for channel_id in channels:
|
||||||
ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}")
|
ikb.button(text=f"Канал {channel_id}", callback_data=f"select_channel:{channel_id}")
|
||||||
|
ikb.button(text="➕ Добавить канал", callback_data="add_channel") # ✅ ДОБАВИЛИ
|
||||||
ikb.button(text="❌ Закрыть", callback_data="menu:close")
|
ikb.button(text="❌ Закрыть", callback_data="menu:close")
|
||||||
ikb.adjust(1)
|
ikb.adjust(1)
|
||||||
return ikb
|
return ikb
|
||||||
|
|
||||||
|
async def get_all_channels() -> List[int]: # ✅ ✅ ✅ ИСПРАВЛЕНО: async!
|
||||||
|
"""Получает ВСЕ каналы из БД"""
|
||||||
|
manager = get_manager()
|
||||||
|
return await manager.get_auto_comment_channels()
|
||||||
|
|
||||||
def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]:
|
def _build_comment_payload(config: dict) -> Tuple[str, InlineKeyboardBuilder]:
|
||||||
full_text = hide_link(config["photo_url"]) + (config["text"] or "")
|
photo_url = (config.get("photo_url") or "").strip()
|
||||||
|
text = config.get("text") or ""
|
||||||
|
|
||||||
|
full_text = (hide_link(photo_url) if photo_url else "") + text
|
||||||
|
|
||||||
keyboard = InlineKeyboardBuilder()
|
keyboard = InlineKeyboardBuilder()
|
||||||
if config.get("button_text") and config.get("button_url"):
|
if config.get("button_text") and config.get("button_url"):
|
||||||
keyboard.button(text=config["button_text"], url=config["button_url"])
|
keyboard.button(text=config["button_text"], url=config["button_url"])
|
||||||
return full_text, keyboard
|
return full_text, keyboard
|
||||||
|
|
||||||
|
|
||||||
def _extract_origin_channel_id(message: Message) -> Optional[int]:
|
def _extract_origin_channel_id(message: Message) -> Optional[int]:
|
||||||
"""
|
|
||||||
Для auto-forward из привязанного канала Telegram обычно проставляет:
|
|
||||||
- message.is_automatic_forward = True
|
|
||||||
- message.forward_from_chat = канал
|
|
||||||
|
|
||||||
Если forward_from_chat вдруг отсутствует — возвращаем None.
|
|
||||||
"""
|
|
||||||
if not message.is_automatic_forward:
|
if not message.is_automatic_forward:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if message.forward_from_chat and message.forward_from_chat.type == "channel":
|
if message.forward_from_chat and message.forward_from_chat.type == "channel":
|
||||||
return message.forward_from_chat.id
|
return message.forward_from_chat.id
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id)
|
# Дедуп: чтобы не комментировать каждый элемент альбома (media_group_id)
|
||||||
_MEDIA_GROUP_SEEN: Dict[Tuple[int, str], float] = {}
|
_MEDIA_GROUP_SEEN: Dict[tuple[int, str], float] = {}
|
||||||
_MEDIA_GROUP_TTL_SEC = 45.0
|
_MEDIA_GROUP_TTL_SEC = 45.0
|
||||||
|
|
||||||
|
|
||||||
def _media_group_should_skip(message: Message) -> bool:
|
def _media_group_should_skip(message: Message) -> bool:
|
||||||
"""
|
|
||||||
Возвращает True если это повторная часть альбома и мы уже комментировали.
|
|
||||||
Ключ: (chat_id, media_group_id).
|
|
||||||
"""
|
|
||||||
if not message.media_group_id:
|
if not message.media_group_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -145,7 +140,6 @@ def _media_group_should_skip(message: Message) -> bool:
|
|||||||
key = (message.chat.id, str(message.media_group_id))
|
key = (message.chat.id, str(message.media_group_id))
|
||||||
last = _MEDIA_GROUP_SEEN.get(key)
|
last = _MEDIA_GROUP_SEEN.get(key)
|
||||||
|
|
||||||
# чистка старых ключей (лениво)
|
|
||||||
if len(_MEDIA_GROUP_SEEN) > 500:
|
if len(_MEDIA_GROUP_SEEN) > 500:
|
||||||
cutoff = now - _MEDIA_GROUP_TTL_SEC
|
cutoff = now - _MEDIA_GROUP_TTL_SEC
|
||||||
for k, t in list(_MEDIA_GROUP_SEEN.items()):
|
for k, t in list(_MEDIA_GROUP_SEEN.items()):
|
||||||
@@ -158,18 +152,72 @@ def _media_group_should_skip(message: Message) -> bool:
|
|||||||
_MEDIA_GROUP_SEEN[key] = now
|
_MEDIA_GROUP_SEEN[key] = now
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_channel_config(channel_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Получает настройки автокомментариев для канала из БД.
|
||||||
|
Ничего "не затирает": если поля отсутствуют — подставляет дефолты.
|
||||||
|
"""
|
||||||
|
manager = get_manager()
|
||||||
|
config = await manager.get_auto_comment_settings(channel_id) or {}
|
||||||
|
|
||||||
|
merged = _defaults()
|
||||||
|
merged.update({k: v for k, v in config.items() if v is not None})
|
||||||
|
|
||||||
|
if "is_enabled" not in config:
|
||||||
|
merged["is_enabled"] = False
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
async def _persist_settings_preserve_enabled(
|
||||||
|
channel_id: int,
|
||||||
|
patch: dict,
|
||||||
|
updated_by: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Надёжное сохранение настроек:
|
||||||
|
- всегда делает "первичное сохранение" через save_auto_comment_settings (чтобы запись точно появилась)
|
||||||
|
- сохраняет старый is_enabled (если было выключено — выключаем обратно после сохранения)
|
||||||
|
"""
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
|
raw = await manager.get_auto_comment_settings(channel_id) or {}
|
||||||
|
was_enabled = bool(raw.get("is_enabled", False))
|
||||||
|
|
||||||
|
merged = _defaults()
|
||||||
|
merged.update({k: v for k, v in raw.items() if v is not None})
|
||||||
|
merged.update({k: v for k, v in patch.items() if v is not None})
|
||||||
|
|
||||||
|
# save_auto_comment_settings у тебя уже используется при включении (значит умеет создавать запись)
|
||||||
|
success = await manager.save_auto_comment_settings(
|
||||||
|
channel_id=channel_id,
|
||||||
|
text=merged.get("text") or "",
|
||||||
|
button_text=merged.get("button_text") or "",
|
||||||
|
button_url=merged.get("button_url") or "",
|
||||||
|
photo_url=merged.get("photo_url") or "",
|
||||||
|
updated_by=updated_by,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Если было выключено — сохраняем выключенным (на случай если save_* включает фичу)
|
||||||
|
if not was_enabled:
|
||||||
|
try:
|
||||||
|
await manager.repo.toggle_auto_comment(
|
||||||
|
channel_id=channel_id,
|
||||||
|
is_enabled=False,
|
||||||
|
updated_by=updated_by,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"toggle_auto_comment failed (preserve disabled): {e}", log_type="CHANNEL")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# CORE: AUTO COMMENTS (discussion group)
|
# CORE: AUTO COMMENTS (discussion group) ✅ ФИКС #3
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.message(F.is_automatic_forward)
|
@router.message(F.is_automatic_forward)
|
||||||
async def auto_comment_from_discussion_forward(message: Message) -> None:
|
async def auto_comment_from_discussion_forward(message: Message) -> None:
|
||||||
"""
|
|
||||||
Ловим пост канала, автоматически пересланный в привязанную группу обсуждений.
|
|
||||||
Комментарий отправляем reply на это сообщение => появляется "под постом".
|
|
||||||
"""
|
|
||||||
# 0) Дедуп альбомов
|
|
||||||
if _media_group_should_skip(message):
|
if _media_group_should_skip(message):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}",
|
f"⏭ Skip media_group duplicate: chat={message.chat.id} media_group_id={message.media_group_id}",
|
||||||
@@ -183,7 +231,6 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
|
|||||||
log_type="CHANNEL"
|
log_type="CHANNEL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1) Канал-источник
|
|
||||||
channel_id = _extract_origin_channel_id(message)
|
channel_id = _extract_origin_channel_id(message)
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -192,23 +239,17 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2) Проверка списка каналов
|
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #3: await!
|
||||||
channels = settings.AUTO_COMMENT_CHANNELS_LIST
|
|
||||||
if not channels:
|
if not channels:
|
||||||
logger.warning("❌ AUTO_COMMENT_CHANNELS_LIST пуст — нечего обрабатывать", log_type="CHANNEL")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if channel_id not in channels:
|
if channel_id not in channels:
|
||||||
logger.debug(f"⏭ Channel {channel_id} not in configured list", log_type="CHANNEL")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3) /test_comment (если админ запостил команду в канале — она тоже прилетит сюда автофорвардом)
|
|
||||||
is_test = False
|
is_test = False
|
||||||
txt = message.text or message.caption or ""
|
txt = message.text or message.caption or ""
|
||||||
if "/test_comment" in txt:
|
if "/test_comment" in txt:
|
||||||
is_test = True
|
is_test = True
|
||||||
|
|
||||||
# 4) Настройки и статус
|
|
||||||
try:
|
try:
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -219,22 +260,21 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
|
|||||||
logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL")
|
logger.debug(f"⏭ Auto-comments disabled for channel={channel_id}", log_type="CHANNEL")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 5) Формируем и отправляем комментарий (reply в группе)
|
|
||||||
try:
|
try:
|
||||||
full_text, keyboard = _build_comment_payload(config)
|
full_text, keyboard = _build_comment_payload(config)
|
||||||
|
|
||||||
sent = await message.reply(
|
sent = await message.reply(
|
||||||
text=full_text,
|
text=full_text,
|
||||||
reply_markup=keyboard.as_markup(),
|
reply_markup=keyboard.as_markup(),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
"✅ Comment sent (discussion reply)\n"
|
f"✅ Comment sent (discussion reply)\n"
|
||||||
f" ├─ Origin channel: {channel_id}\n"
|
f" ├─ Origin channel: {channel_id}\n"
|
||||||
f" ├─ Discussion chat: {message.chat.id}\n"
|
f" ├─ Discussion chat: {message.chat.id}\n"
|
||||||
f" ├─ Forward msg id: {message.message_id}\n"
|
f" ├─ Forward msg id: {message.message_id}\n"
|
||||||
f" └─ Comment msg id: {sent.message_id}\n"
|
f" ├─ Comment msg id: {sent.message_id}\n"
|
||||||
f" └─ Test mode: {is_test}",
|
f" └─ Test mode: {is_test}",
|
||||||
log_type="CHANNEL"
|
log_type="CHANNEL"
|
||||||
)
|
)
|
||||||
@@ -253,19 +293,60 @@ async def auto_comment_from_discussion_forward(message: Message) -> None:
|
|||||||
log_type="CHANNEL"
|
log_type="CHANNEL"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"❌ Unexpected error while sending comment: {e}", log_type="CHANNEL")
|
||||||
f"❌ Unexpected error while sending comment: {e}",
|
|
||||||
log_type="CHANNEL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# DIAGNOSTICS
|
# ✅ НОВЫЕ ХЕНДЛЕРЫ ДЛЯ ДОБАВЛЕНИЯ КАНАЛА
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "add_channel", IsAdmin())
|
||||||
|
async def add_channel_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
await state.update_data(action="add_channel")
|
||||||
|
await state.set_state(CommentEditStates.waiting_add_channel)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=(
|
||||||
|
"➕ <b>ДОБАВИТЬ КАНАЛ</b>\n\n"
|
||||||
|
"Отправьте ID канала (число с минусом):\n"
|
||||||
|
"<code>Пример: -1003876862007</code>\n\n"
|
||||||
|
"💡 @userinfobot для получения ID\n\n"
|
||||||
|
"Для отмены: /cancel"
|
||||||
|
),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(CommentEditStates.waiting_add_channel, IsAdmin())
|
||||||
|
async def process_add_channel(message: Message, state: FSMContext) -> None:
|
||||||
|
if message.text == "/cancel":
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Отменено")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel_id = int(message.text.strip())
|
||||||
|
if not str(channel_id).startswith('-'):
|
||||||
|
raise ValueError()
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Неверный ID. Пример: <code>-1003876862007</code>", parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager = get_manager()
|
||||||
|
success = await manager.add_auto_comment_channel(channel_id, message.from_user.id)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
await message.answer(f"✅ <b>Канал добавлен!</b>\n<code>{channel_id}</code>\n/redactcomment", parse_mode="HTML")
|
||||||
|
else:
|
||||||
|
await message.answer(f"❌ Канал <code>{channel_id}</code> уже существует!", parse_mode="HTML")
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# DIAGNOSTICS ✅ ФИКС #2
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):diagnostic"))
|
||||||
async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
||||||
"""Запускает полную диагностику канала"""
|
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
bot: Bot = callback.bot
|
bot: Bot = callback.bot
|
||||||
|
|
||||||
@@ -273,13 +354,11 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
|
|
||||||
diagnostic_text = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
diagnostic_text = "🔍 <b>ДИАГНОСТИКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
||||||
|
|
||||||
# 1) ENV settings
|
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #2: await!
|
||||||
channels = settings.AUTO_COMMENT_CHANNELS_LIST
|
|
||||||
diagnostic_text += "1️⃣ <b>Настройки:</b>\n"
|
diagnostic_text += "1️⃣ <b>Настройки:</b>\n"
|
||||||
diagnostic_text += f" ├─ AUTO_COMMENT_CHANNELS_LIST: <code>{channels}</code>\n"
|
diagnostic_text += f" ├─ Каналы из БД: <code>{channels}</code>\n"
|
||||||
diagnostic_text += f" └─ Канал в списке: {'✅' if channel_id in channels else '❌'}\n\n"
|
diagnostic_text += f" └─ Канал в списке: {'✅' if channel_id in channels else '❌'}\n\n"
|
||||||
|
|
||||||
# 2) DB config
|
|
||||||
diagnostic_text += "2️⃣ <b>База данных:</b>\n"
|
diagnostic_text += "2️⃣ <b>База данных:</b>\n"
|
||||||
try:
|
try:
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
@@ -292,7 +371,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
||||||
config = None
|
config = None
|
||||||
|
|
||||||
# 3) Bot status in channel
|
|
||||||
diagnostic_text += "3️⃣ <b>Бот в канале:</b>\n"
|
diagnostic_text += "3️⃣ <b>Бот в канале:</b>\n"
|
||||||
try:
|
try:
|
||||||
member = await bot.get_chat_member(channel_id, bot.id)
|
member = await bot.get_chat_member(channel_id, bot.id)
|
||||||
@@ -313,7 +391,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
||||||
|
|
||||||
# 4) Linked discussion group
|
|
||||||
diagnostic_text += "4️⃣ <b>Привязанная группа обсуждений:</b>\n"
|
diagnostic_text += "4️⃣ <b>Привязанная группа обсуждений:</b>\n"
|
||||||
linked_chat_id = None
|
linked_chat_id = None
|
||||||
try:
|
try:
|
||||||
@@ -327,7 +404,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
||||||
|
|
||||||
# 5) Bot status in discussion group
|
|
||||||
diagnostic_text += "5️⃣ <b>Бот в группе обсуждений:</b>\n"
|
diagnostic_text += "5️⃣ <b>Бот в группе обсуждений:</b>\n"
|
||||||
if not linked_chat_id:
|
if not linked_chat_id:
|
||||||
diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n"
|
diagnostic_text += " └─ ⏭ Пропущено (группа не найдена)\n\n"
|
||||||
@@ -340,7 +416,6 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
else:
|
else:
|
||||||
diagnostic_text += " ├─ Присутствует: ❌\n"
|
diagnostic_text += " ├─ Присутствует: ❌\n"
|
||||||
|
|
||||||
# can_send_messages бывает не у всех типов, поэтому hasattr
|
|
||||||
if hasattr(gmember, "can_send_messages"):
|
if hasattr(gmember, "can_send_messages"):
|
||||||
diagnostic_text += f" └─ can_send_messages: {'✅' if gmember.can_send_messages else '❌'}\n\n"
|
diagnostic_text += f" └─ can_send_messages: {'✅' if gmember.can_send_messages else '❌'}\n\n"
|
||||||
else:
|
else:
|
||||||
@@ -350,33 +425,37 @@ async def diagnostic_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
diagnostic_text += f" └─ ❌ Ошибка: {e}\n\n"
|
||||||
|
|
||||||
# Recommendations
|
|
||||||
diagnostic_text += "💡 <b>Что должно быть для работы:</b>\n"
|
diagnostic_text += "💡 <b>Что должно быть для работы:</b>\n"
|
||||||
if channel_id not in channels:
|
if channel_id not in channels:
|
||||||
diagnostic_text += " • Добавьте канал в AUTO_COMMENT_CHANNELS\n"
|
diagnostic_text += " • Добавьте канал ➕\n"
|
||||||
diagnostic_text += " • Включите автокомментарии (🔄 Переключить)\n"
|
diagnostic_text += (
|
||||||
diagnostic_text += " • Подключите discussion group к каналу\n"
|
" • Включите автокомментарии (🔄 Переключить)\n"
|
||||||
diagnostic_text += " • Дайте боту право писать в группе обсуждений\n"
|
" • Подключите discussion group к каналу\n"
|
||||||
diagnostic_text += " • Для теста: отправьте пост в канал или пост с /test_comment\n"
|
" • Дайте боту право писать в группе обсуждений\n"
|
||||||
|
" • Для теста: отправьте пост в канал или пост с /test_comment\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
|
await callback.message.answer(text=diagnostic_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# ADMIN UI: COMMAND + MENUS
|
# ADMIN UI: COMMAND + MENUS ✅ ФИКС #1
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.message(Command("redactcomment"), IsAdmin())
|
@router.callback_query(F.data.casefold() == "redactcomment", IsAdmin())
|
||||||
|
@router.message(Command(*COMMANDS["redactcomment"], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||||
|
@log_action(action_name="START_COMMAND", log_args=True)
|
||||||
async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
|
async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
|
||||||
"""Открывает меню управления автокомментариями"""
|
channels = await get_all_channels() # ✅ ✅ ✅ ФИКС #1: await!
|
||||||
channels = settings.AUTO_COMMENT_CHANNELS_LIST
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
if not channels:
|
if not channels:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Каналы не настроены</b>\n\n"
|
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
|
||||||
"Добавьте ID каналов в .env файл:\n"
|
"🚫 <b>Каналы не настроены</b>\n\n"
|
||||||
"<code>AUTO_COMMENT_CHANNELS=-1003876862007</code>\n\n"
|
"👆 <b>➕ Добавить канал</b>",
|
||||||
"💡 Узнать ID канала: перешлите пост из канала боту @userinfobot",
|
reply_markup=create_channels_menu([]).as_markup(), # ✅ Пустое + кнопка
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -385,33 +464,14 @@ async def redact_comment_cmd(message: Message, state: FSMContext) -> None:
|
|||||||
await show_channel_menu(message, channels[0])
|
await show_channel_menu(message, channels[0])
|
||||||
else:
|
else:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"📢 <b>УПРАВЛЕНИЕ АВТОКОММЕНТАРИЯМИ</b>\n\n"
|
"📢 <b>Выберите канал:</b>",
|
||||||
"Выберите канал для настройки:",
|
|
||||||
reply_markup=create_channels_menu(channels).as_markup(),
|
reply_markup=create_channels_menu(channels).as_markup(),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def show_channel_menu(message: Message, channel_id: int) -> None:
|
async def show_channel_menu(message: Message, channel_id: int) -> None:
|
||||||
"""Показывает меню настроек для конкретного канала"""
|
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
|
output = _render_menu_text(channel_id, config)
|
||||||
|
|
||||||
text = config.get("text") or ""
|
|
||||||
photo_url = config.get("photo_url") or ""
|
|
||||||
text_preview = (text[:100] + "...") if len(text) > 100 else text
|
|
||||||
photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
|
|
||||||
|
|
||||||
output = (
|
|
||||||
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
|
||||||
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
|
|
||||||
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
|
|
||||||
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
|
|
||||||
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
|
|
||||||
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
|
|
||||||
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
|
|
||||||
f"💡 Выберите действие:"
|
|
||||||
)
|
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=output,
|
text=output,
|
||||||
@@ -419,30 +479,15 @@ async def show_channel_menu(message: Message, channel_id: int) -> None:
|
|||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("select_channel:"))
|
@router.callback_query(F.data.startswith("select_channel:"))
|
||||||
async def select_channel_callback(callback: CallbackQuery) -> None:
|
async def select_channel_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
"""Обработка выбора канала из списка"""
|
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
|
output = _render_menu_text(channel_id, config)
|
||||||
|
|
||||||
text = config.get("text") or ""
|
|
||||||
photo_url = config.get("photo_url") or ""
|
|
||||||
text_preview = (text[:100] + "...") if len(text) > 100 else text
|
|
||||||
photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
|
|
||||||
|
|
||||||
output = (
|
|
||||||
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
|
||||||
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
|
|
||||||
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
|
|
||||||
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
|
|
||||||
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
|
|
||||||
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
|
|
||||||
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
|
|
||||||
f"💡 Выберите действие:"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=output,
|
text=output,
|
||||||
reply_markup=create_main_menu(channel_id).as_markup(),
|
reply_markup=create_main_menu(channel_id).as_markup(),
|
||||||
@@ -450,18 +495,18 @@ async def select_channel_callback(callback: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# EDIT TEXT
|
# EDIT TEXT
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):text"), IsAdmin())
|
||||||
async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
await state.update_data(channel_id=channel_id)
|
await state.update_data(channel_id=channel_id)
|
||||||
await state.set_state(CommentEditStates.waiting_text)
|
await state.set_state(CommentEditStates.waiting_text)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=(
|
text=(
|
||||||
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
|
"📝 <b>РЕДАКТИРОВАНИЕ ТЕКСТА</b>\n\n"
|
||||||
@@ -473,23 +518,31 @@ async def edit_text_callback(callback: CallbackQuery, state: FSMContext) -> None
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(CommentEditStates.waiting_text, IsAdmin())
|
||||||
@router.message(CommentEditStates.waiting_text)
|
|
||||||
async def process_text_input(message: Message, state: FSMContext) -> None:
|
async def process_text_input(message: Message, state: FSMContext) -> None:
|
||||||
if message.text == "/cancel":
|
if (message.text or "").strip() == "/cancel":
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("❌ Отменено")
|
await message.answer("❌ Отменено")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
channel_id = data.get("channel_id")
|
channel_id = data.get("channel_id")
|
||||||
|
if not channel_id:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
|
||||||
|
return
|
||||||
|
|
||||||
manager = get_manager()
|
new_text = message.text or ""
|
||||||
success = await manager.update_auto_comment_text(
|
|
||||||
channel_id=channel_id,
|
try:
|
||||||
text=message.text or "",
|
success = await _persist_settings_preserve_enabled(
|
||||||
|
channel_id=int(channel_id),
|
||||||
|
patch={"text": new_text},
|
||||||
updated_by=message.from_user.id
|
updated_by=message.from_user.id
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"update text failed: {e}", log_type="CHANNEL")
|
||||||
|
success = False
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
@@ -500,21 +553,21 @@ async def process_text_input(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer(f"✅ <b>Текст обновлён!</b>", parse_mode="HTML")
|
await message.answer("✅ <b>Текст обновлён!</b>", parse_mode="HTML")
|
||||||
await show_channel_menu(message, channel_id)
|
await show_channel_menu(message, int(channel_id))
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# EDIT BUTTON
|
# EDIT BUTTON
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):button"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):button"), IsAdmin())
|
||||||
async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
await state.update_data(channel_id=channel_id)
|
await state.update_data(channel_id=channel_id)
|
||||||
await state.set_state(CommentEditStates.waiting_button_text)
|
await state.set_state(CommentEditStates.waiting_button_text)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=(
|
text=(
|
||||||
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
|
"🔘 <b>РЕДАКТИРОВАНИЕ КНОПКИ</b>\n\n"
|
||||||
@@ -525,10 +578,9 @@ async def edit_button_callback(callback: CallbackQuery, state: FSMContext) -> No
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(CommentEditStates.waiting_button_text, IsAdmin())
|
||||||
@router.message(CommentEditStates.waiting_button_text)
|
|
||||||
async def process_button_text(message: Message, state: FSMContext) -> None:
|
async def process_button_text(message: Message, state: FSMContext) -> None:
|
||||||
if message.text == "/cancel":
|
if (message.text or "").strip() == "/cancel":
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("❌ Отменено")
|
await message.answer("❌ Отменено")
|
||||||
return
|
return
|
||||||
@@ -538,22 +590,21 @@ async def process_button_text(message: Message, state: FSMContext) -> None:
|
|||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=(
|
text=(
|
||||||
f"✅ Текст кнопки: <b>{message.text}</b>\n\n"
|
f"✅ Текст кнопки: <b>{(message.text or '').strip()}</b>\n\n"
|
||||||
f"<b>Шаг 2 из 2:</b> Отправьте URL кнопки\n\n"
|
f"<b>Шаг 2 из 2:</b> Отправьте URL кнопки\n\n"
|
||||||
f"Для отмены: /cancel"
|
f"Для отмены: /cancel"
|
||||||
),
|
),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.message(CommentEditStates.waiting_button_url, IsAdmin())
|
||||||
@router.message(CommentEditStates.waiting_button_url)
|
|
||||||
async def process_button_url(message: Message, state: FSMContext) -> None:
|
async def process_button_url(message: Message, state: FSMContext) -> None:
|
||||||
if message.text == "/cancel":
|
if (message.text or "").strip() == "/cancel":
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("❌ Отменено")
|
await message.answer("❌ Отменено")
|
||||||
return
|
return
|
||||||
|
|
||||||
url = message.text or ""
|
url = (message.text or "").strip()
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
|
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
|
||||||
@@ -563,37 +614,44 @@ async def process_button_url(message: Message, state: FSMContext) -> None:
|
|||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
channel_id = data.get("channel_id")
|
channel_id = data.get("channel_id")
|
||||||
button_text = data.get("button_text") or ""
|
button_text = (data.get("button_text") or "").strip()
|
||||||
|
|
||||||
manager = get_manager()
|
if not channel_id:
|
||||||
success = await manager.update_auto_comment_button(
|
await state.clear()
|
||||||
channel_id=channel_id,
|
await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
|
||||||
button_text=button_text,
|
return
|
||||||
button_url=url,
|
|
||||||
|
try:
|
||||||
|
success = await _persist_settings_preserve_enabled(
|
||||||
|
channel_id=int(channel_id),
|
||||||
|
patch={"button_text": button_text, "button_url": url},
|
||||||
updated_by=message.from_user.id
|
updated_by=message.from_user.id
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"update button failed: {e}", log_type="CHANNEL")
|
||||||
|
success = False
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML")
|
await message.answer("✅ <b>Кнопка обновлена!</b>", parse_mode="HTML")
|
||||||
await show_channel_menu(message, channel_id)
|
await show_channel_menu(message, int(channel_id))
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# EDIT PHOTO URL
|
# EDIT PHOTO URL
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):photo"), IsAdmin())
|
||||||
async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
await state.update_data(channel_id=channel_id)
|
await state.update_data(channel_id=channel_id)
|
||||||
await state.set_state(CommentEditStates.waiting_photo_url)
|
await state.set_state(CommentEditStates.waiting_photo_url)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=(
|
text=(
|
||||||
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
|
"🖼 <b>РЕДАКТИРОВАНИЕ ФОТО</b>\n\n"
|
||||||
@@ -604,15 +662,14 @@ async def edit_photo_callback(callback: CallbackQuery, state: FSMContext) -> Non
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.message(CommentEditStates.waiting_photo_url, IsAdmin())
|
||||||
@router.message(CommentEditStates.waiting_photo_url)
|
|
||||||
async def process_photo_url(message: Message, state: FSMContext) -> None:
|
async def process_photo_url(message: Message, state: FSMContext) -> None:
|
||||||
if message.text == "/cancel":
|
if (message.text or "").strip() == "/cancel":
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("❌ Отменено")
|
await message.answer("❌ Отменено")
|
||||||
return
|
return
|
||||||
|
|
||||||
url = message.text or ""
|
url = (message.text or "").strip()
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
|
"❌ <b>Неверный формат URL</b>\n\nURL должен начинаться с http:// или https://",
|
||||||
@@ -622,35 +679,42 @@ async def process_photo_url(message: Message, state: FSMContext) -> None:
|
|||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
channel_id = data.get("channel_id")
|
channel_id = data.get("channel_id")
|
||||||
|
if not channel_id:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("❌ Не выбран канал. Откройте меню заново: /redactcomment")
|
||||||
|
return
|
||||||
|
|
||||||
manager = get_manager()
|
try:
|
||||||
success = await manager.update_auto_comment_photo(
|
success = await _persist_settings_preserve_enabled(
|
||||||
channel_id=channel_id,
|
channel_id=int(channel_id),
|
||||||
photo_url=url,
|
patch={"photo_url": url},
|
||||||
updated_by=message.from_user.id
|
updated_by=message.from_user.id
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"update photo failed: {e}", log_type="CHANNEL")
|
||||||
|
success = False
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
await message.answer("❌ Ошибка сохранения", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка сохранения</b>\n\nПопробуйте ещё раз через /redactcomment", parse_mode="HTML")
|
||||||
return
|
return
|
||||||
|
|
||||||
await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML")
|
await message.answer(hide_link(url) + "✅ <b>Фото обновлено!</b>", parse_mode="HTML")
|
||||||
await show_channel_menu(message, channel_id)
|
await show_channel_menu(message, int(channel_id))
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# PREVIEW
|
# PREVIEW
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):preview"), IsAdmin())
|
||||||
async def preview_comment_callback(callback: CallbackQuery) -> None:
|
async def preview_comment_callback(callback: CallbackQuery) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
|
|
||||||
full_text, keyboard = _build_comment_payload(config)
|
full_text, keyboard = _build_comment_payload(config)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
|
text=f"👁 <b>ПРЕВЬЮ КОММЕНТАРИЯ</b>\n\n{full_text}",
|
||||||
reply_markup=keyboard.as_markup(),
|
reply_markup=keyboard.as_markup(),
|
||||||
@@ -658,12 +722,11 @@ async def preview_comment_callback(callback: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
await callback.answer("✅ Превью отправлено")
|
await callback.answer("✅ Превью отправлено")
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# TOGGLE
|
# TOGGLE
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):toggle"), IsAdmin())
|
||||||
async def toggle_comment_callback(callback: CallbackQuery) -> None:
|
async def toggle_comment_callback(callback: CallbackQuery) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
@@ -695,37 +758,21 @@ async def toggle_comment_callback(callback: CallbackQuery) -> None:
|
|||||||
|
|
||||||
await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True)
|
await callback.answer(f"Автокомментарии {'✅ включены' if new_status else '❌ выключены'}", show_alert=True)
|
||||||
|
|
||||||
# Обновляем меню
|
|
||||||
config = await get_channel_config(channel_id)
|
config = await get_channel_config(channel_id)
|
||||||
status_emoji = "✅ Включено" if config.get("is_enabled") else "❌ Выключено"
|
output = _render_menu_text(channel_id, config)
|
||||||
text = config.get("text") or ""
|
|
||||||
photo_url = config.get("photo_url") or ""
|
|
||||||
text_preview = (text[:100] + "...") if len(text) > 100 else text
|
|
||||||
photo_preview = (photo_url[:60] + "...") if len(photo_url) > 60 else photo_url
|
|
||||||
|
|
||||||
output = (
|
|
||||||
f"⚙️ <b>НАСТРОЙКА АВТОКОММЕНТАРИЕВ</b>\n\n"
|
|
||||||
f"📢 <b>Канал:</b> <code>{channel_id}</code>\n"
|
|
||||||
f"🔘 <b>Статус:</b> {status_emoji}\n\n"
|
|
||||||
f"📝 <b>Текст:</b>\n{text_preview or '<i>(пусто)</i>'}\n\n"
|
|
||||||
f"🔘 <b>Кнопка:</b> {config.get('button_text') or '<i>(нет)</i>'}\n"
|
|
||||||
f"🔗 <b>URL:</b> <code>{config.get('button_url') or ''}</code>\n\n"
|
|
||||||
f"🖼 <b>Фото:</b>\n<code>{photo_preview}</code>\n\n"
|
|
||||||
f"💡 Выберите действие:"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=output,
|
text=output,
|
||||||
reply_markup=create_main_menu(channel_id).as_markup(),
|
reply_markup=create_main_menu(channel_id).as_markup(),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# DELETE SETTINGS
|
# DELETE SETTINGS
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|
||||||
@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"))
|
@router.callback_query(F.data.regexp(r"edit:(-?\d+):delete"), IsAdmin())
|
||||||
async def delete_comment_callback(callback: CallbackQuery) -> None:
|
async def delete_comment_callback(callback: CallbackQuery) -> None:
|
||||||
channel_id = int(callback.data.split(":")[1])
|
channel_id = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
@@ -737,6 +784,8 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await callback.answer("🗑 Настройки удалены", show_alert=True)
|
await callback.answer("🗑 Настройки удалены", show_alert=True)
|
||||||
|
|
||||||
|
if callback.message:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=(
|
text=(
|
||||||
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
|
"🗑 <b>НАСТРОЙКИ УДАЛЕНЫ</b>\n\n"
|
||||||
@@ -748,23 +797,3 @@ async def delete_comment_callback(callback: CallbackQuery) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
|
||||||
# CLOSE / CANCEL
|
|
||||||
# ======================================================================
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "menu:close")
|
|
||||||
async def close_menu_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
|
||||||
await state.clear()
|
|
||||||
await callback.message.delete()
|
|
||||||
await callback.answer("❌ Меню закрыто")
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("cancel"))
|
|
||||||
async def cancel_handler(message: Message, state: FSMContext) -> None:
|
|
||||||
current_state = await state.get_state()
|
|
||||||
if current_state is None:
|
|
||||||
await message.answer("❌ Нечего отменять")
|
|
||||||
return
|
|
||||||
|
|
||||||
await state.clear()
|
|
||||||
await message.answer("✅ Действие отменено")
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from .admins import router as admin_router
|
|||||||
from .notifications import router as notifications_router
|
from .notifications import router as notifications_router
|
||||||
from .id import router as id_router
|
from .id import router as id_router
|
||||||
from .emoji import router as emoji_router
|
from .emoji import router as emoji_router
|
||||||
|
from .cancel import router as cancel_router
|
||||||
|
from .bot_settings import router as setting_router
|
||||||
|
|
||||||
# Настройка экспорта и роутера
|
# Настройка экспорта и роутера
|
||||||
__all__ = ("router",)
|
__all__ = ("router",)
|
||||||
@@ -19,6 +21,7 @@ router: Router = Router(name=__name__)
|
|||||||
|
|
||||||
# Подключение роутеров
|
# Подключение роутеров
|
||||||
router.include_routers(
|
router.include_routers(
|
||||||
|
cancel_router,
|
||||||
notifications_router,
|
notifications_router,
|
||||||
report_router,
|
report_router,
|
||||||
admin_router,
|
admin_router,
|
||||||
@@ -30,4 +33,5 @@ conflict_router,
|
|||||||
stats_router,
|
stats_router,
|
||||||
id_router,
|
id_router,
|
||||||
emoji_router,
|
emoji_router,
|
||||||
|
setting_router,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,248 +6,197 @@ from aiogram.filters import Command
|
|||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from bot import bot # ← ДОБАВЬ ЭТОТ ИМПОРТ
|
||||||
from bot.filters.admin import IsSuperAdmin
|
from bot.filters.admin import IsSuperAdmin
|
||||||
from configs import settings, COMMANDS
|
from configs import settings, COMMANDS
|
||||||
from database import get_manager
|
from database import get_manager
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
from bot.utils.decorators import log_action
|
from bot.utils import log_action, tg_emoji
|
||||||
|
|
||||||
__all__ = ("router",)
|
__all__ = ("router",)
|
||||||
|
|
||||||
router: Router = Router(name="admin_management_router")
|
router: Router = Router(name="admin_management_router")
|
||||||
|
|
||||||
|
|
||||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
|
||||||
|
|
||||||
def parse_user_id(text: str, command: str) -> tuple[bool, str | int]:
|
def parse_user_id(text: str, command: str) -> tuple[bool, str | int]:
|
||||||
"""
|
|
||||||
Парсит ID пользователя из команды.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Полный текст сообщения
|
|
||||||
command: Название команды
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(success, result): result это либо user_id (int), либо текст ошибки (str)
|
|
||||||
"""
|
|
||||||
parts = text.split(maxsplit=1)
|
parts = text.split(maxsplit=1)
|
||||||
|
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return False, f"❌ Использование: <code>/{command} <ID></code>"
|
return False, f'{tg_emoji("4961187972822074653")} Использование: <code>/{command} <ID></code>'
|
||||||
|
|
||||||
user_id_str = parts[1].strip()
|
user_id_str = parts[1].strip()
|
||||||
|
|
||||||
# Валидация ID
|
|
||||||
try:
|
try:
|
||||||
user_id = int(user_id_str)
|
user_id = int(user_id_str)
|
||||||
|
|
||||||
if user_id <= 0:
|
if user_id <= 0:
|
||||||
return False, "❌ ID должен быть положительным числом"
|
return False, f'{tg_emoji("4961187972822074653")} ID должен быть положительным числом'
|
||||||
|
if user_id > 9999999999:
|
||||||
if user_id > 9999999999: # Максимальный Telegram ID
|
return False, f'{tg_emoji("4961187972822074653")} Некорректный ID пользователя'
|
||||||
return False, "❌ Некорректный ID пользователя"
|
|
||||||
|
|
||||||
return True, user_id
|
return True, user_id
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False, "❌ ID должен быть числом"
|
return False, f'{tg_emoji("4961187972822074653")} ID должен быть числом'
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_display_name(user_id: int) -> str:
|
||||||
|
"""Получает имя пользователя или username или ID"""
|
||||||
|
try:
|
||||||
|
chat = await bot.get_chat(user_id)
|
||||||
|
name = f"{chat.first_name or ''} {chat.last_name or ''}".strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
if chat.username:
|
||||||
|
return f"@{chat.username}"
|
||||||
|
return str(user_id)
|
||||||
|
except:
|
||||||
|
return str(user_id)
|
||||||
|
|
||||||
|
|
||||||
def format_admin_info(user_id: int, username: str | None = None) -> str:
|
def format_admin_info(user_id: int, username: str | None = None) -> str:
|
||||||
"""Форматирует информацию об админе"""
|
|
||||||
if username:
|
if username:
|
||||||
return f"<code>{user_id}</code> (@{username})"
|
return f'<code>{user_id}</code> (@{username})'
|
||||||
return f"<code>{user_id}</code>"
|
return f'<code>{user_id}</code>'
|
||||||
|
|
||||||
|
|
||||||
def get_refresh_admins_kb():
|
def get_refresh_admins_kb():
|
||||||
"""Клавиатура для обновления списка админов"""
|
|
||||||
ikb = InlineKeyboardBuilder()
|
ikb = InlineKeyboardBuilder()
|
||||||
ikb.button(text="🔄 Обновить", callback_data="listadmins:refresh")
|
ikb.button(text='🔄 Обновить', callback_data='listadmins:refresh')
|
||||||
ikb.button(text="➕ Добавить", callback_data="admin:help_add")
|
ikb.button(text='➕ Добавить', callback_data='admin:help_add')
|
||||||
ikb.adjust(2)
|
ikb.adjust(2)
|
||||||
return ikb.as_markup()
|
return ikb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
# ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА =================
|
# ================= ДОБАВЛЕНИЕ АДМИНИСТРАТОРА =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("addadmin", ["addadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
@router.message(Command(*COMMANDS.get('addadmin', ['addadmin']), prefix=settings.PREFIX, ignore_case=True),
|
||||||
IsSuperAdmin())
|
IsSuperAdmin())
|
||||||
@log_action(action_name="ADD_ADMIN", log_args=True)
|
@log_action(action_name='ADD_ADMIN', log_args=True)
|
||||||
async def add_admin_cmd(message: Message) -> None:
|
async def add_admin_cmd(message: Message) -> None:
|
||||||
"""
|
success, result = parse_user_id(message.text, 'addadmin')
|
||||||
Добавляет нового администратора бота.
|
|
||||||
|
|
||||||
Доступно только владельцам бота (OWNER_ID).
|
|
||||||
|
|
||||||
Использование: /addadmin <ID>
|
|
||||||
Пример: /addadmin 123456789
|
|
||||||
"""
|
|
||||||
success, result = parse_user_id(message.text, "addadmin")
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
await message.answer(result, parse_mode="HTML")
|
await message.answer(result, parse_mode='HTML')
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = result
|
user_id = result
|
||||||
|
|
||||||
# Проверка: нельзя добавить самого себя
|
|
||||||
if user_id == message.from_user.id:
|
if user_id == message.from_user.id:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Вы уже владелец бота</b>\n\n"
|
f'{tg_emoji("4963024861615096794")} <b>Вы уже владелец бота</b>\n\n'
|
||||||
"Вам не нужно добавлять себя в администраторы",
|
'Вам не нужно добавлять себя в администраторы',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверка: нельзя добавить другого владельца
|
|
||||||
if user_id in settings.OWNER_ID:
|
if user_id in settings.OWNER_ID:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Этот пользователь уже владелец бота</b>\n\n"
|
f'{tg_emoji("4963024861615096794")} <b>Этот пользователь уже владелец бота</b>\n\n'
|
||||||
"Владельцы имеют полные права автоматически",
|
'Владельцы имеют полные права автоматически',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Проверяем, уже админ ли
|
|
||||||
is_already_admin = await manager.is_admin(user_id)
|
is_already_admin = await manager.is_admin(user_id)
|
||||||
|
|
||||||
if is_already_admin:
|
if is_already_admin:
|
||||||
|
display_name = await get_user_display_name(user_id)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"⚠️ Пользователь {format_admin_info(user_id)} уже является администратором",
|
f'{tg_emoji("4963024861615096794")} Пользователь <b>{display_name}</b> уже является администратором',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Добавляем администратора
|
added = await manager.add_admin(user_id=user_id, added_by=message.from_user.id)
|
||||||
added = await manager.add_admin(
|
|
||||||
user_id=user_id,
|
|
||||||
added_by=message.from_user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if added:
|
if added:
|
||||||
|
display_name = await get_user_display_name(user_id)
|
||||||
text = (
|
text = (
|
||||||
f"✅ <b>Администратор добавлен</b>\n\n"
|
f'{tg_emoji("4963010134172239128")} <b>Администратор добавлен</b>\n\n'
|
||||||
f"👤 ID: {format_admin_info(user_id)}\n"
|
f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
|
||||||
f"👑 Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
|
f'{tg_emoji("4963343509533754468")} Имя: <b>{display_name}</b>\n'
|
||||||
f"📋 Права администратора:\n"
|
f'{tg_emoji("4963343509533754468")} Добавил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
|
||||||
f"├─ Управление банвордами\n"
|
f'{tg_emoji("4961106084975608869")} Права администратора:\n'
|
||||||
f"├─ Просмотр статистики\n"
|
f'├─ Управление банвордами\n'
|
||||||
f"├─ Активация режимов модерации\n"
|
f'├─ Просмотр статистики\n'
|
||||||
f"└─ Все команды бота\n\n"
|
f'├─ Активация режимов модерации\n'
|
||||||
f"⚠️ <i>Не может управлять другими админами</i>\n"
|
f'└─ Все команды бота\n\n'
|
||||||
f"Список админов: /listadmins"
|
f'{tg_emoji("4963024861615096794")} <i>Не может управлять другими админами</i>\n'
|
||||||
)
|
f'Список админов: <b>/listadmins</b>'
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Администратор добавлен: {user_id} (добавил: {message.from_user.id})",
|
|
||||||
log_type="ADMIN_MGMT"
|
|
||||||
)
|
)
|
||||||
|
logger.info(f'Администратор добавлен: {user_id} (добавил: {message.from_user.id})', log_type='ADMIN_MGMT')
|
||||||
else:
|
else:
|
||||||
text = "❌ <b>Ошибка добавления администратора</b>\n\nПопробуйте позже"
|
text = f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления администратора</b>\n\nПопробуйте позже'
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode='HTML')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка добавления администратора: {e}", log_type="ADMIN_MGMT")
|
logger.error(f'Ошибка добавления администратора: {e}', log_type='ADMIN_MGMT')
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка добавления</b>\n\nПопробуйте позже', parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
|
# ================= УДАЛЕНИЕ АДМИНИСТРАТОРА =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("remadmin", ["remadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
@router.message(Command(*COMMANDS.get('remadmin', ['remadmin']), prefix=settings.PREFIX, ignore_case=True),
|
||||||
IsSuperAdmin())
|
IsSuperAdmin())
|
||||||
@log_action(action_name="REMOVE_ADMIN", log_args=True)
|
@log_action(action_name='REMOVE_ADMIN', log_args=True)
|
||||||
async def remove_admin_cmd(message: Message) -> None:
|
async def remove_admin_cmd(message: Message) -> None:
|
||||||
"""
|
success, result = parse_user_id(message.text, 'remadmin')
|
||||||
Удаляет администратора бота.
|
|
||||||
|
|
||||||
Доступно только владельцам бота (OWNER_ID).
|
|
||||||
|
|
||||||
Использование: /remadmin <ID>
|
|
||||||
Пример: /remadmin 123456789
|
|
||||||
"""
|
|
||||||
success, result = parse_user_id(message.text, "remadmin")
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
await message.answer(result, parse_mode="HTML")
|
await message.answer(result, parse_mode='HTML')
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = result
|
user_id = result
|
||||||
|
|
||||||
# Проверка: нельзя удалить владельца
|
|
||||||
if user_id in settings.OWNER_ID:
|
if user_id in settings.OWNER_ID:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Нельзя удалить владельца</b>\n\n"
|
f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить владельца</b>\n\n'
|
||||||
"Владельцы имеют права постоянно",
|
'Владельцы имеют права постоянно',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверка: нельзя удалить самого себя (если вы владелец)
|
|
||||||
if user_id == message.from_user.id:
|
if user_id == message.from_user.id:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Нельзя удалить самого себя</b>",
|
f'{tg_emoji("4963024861615096794")} <b>Нельзя удалить самого себя</b>',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Проверяем, является ли администратором
|
|
||||||
is_admin = await manager.is_admin(user_id)
|
is_admin = await manager.is_admin(user_id)
|
||||||
|
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
|
display_name = await get_user_display_name(user_id)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"⚠️ Пользователь {format_admin_info(user_id)} не является администратором",
|
f'{tg_emoji("4963024861615096794")} Пользователь <b>{display_name}</b> не является администратором',
|
||||||
parse_mode="HTML"
|
parse_mode='HTML'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Удаляем администратора
|
|
||||||
removed = await manager.remove_admin(user_id=user_id)
|
removed = await manager.remove_admin(user_id=user_id)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
|
display_name = await get_user_display_name(user_id)
|
||||||
text = (
|
text = (
|
||||||
f"🗑 <b>Администратор удалён</b>\n\n"
|
f'🗑 <b>Администратор удалён</b>\n\n'
|
||||||
f"👤 ID: {format_admin_info(user_id)}\n"
|
f'{tg_emoji("4961064956368782417")} ID: {format_admin_info(user_id)}\n'
|
||||||
f"👑 Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n"
|
f'{tg_emoji("4961064956368782417")} Имя: <b>{display_name}</b>\n'
|
||||||
f"⚠️ <i>Пользователь больше не имеет доступа к командам бота</i>"
|
f'{tg_emoji("4963343509533754468")} Удалил: {format_admin_info(message.from_user.id, message.from_user.username)}\n\n'
|
||||||
)
|
f'{tg_emoji("4963024861615096794")} <i>Пользователь больше не имеет доступа к командам бота</i>'
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Администратор удалён: {user_id} (удалил: {message.from_user.id})",
|
|
||||||
log_type="ADMIN_MGMT"
|
|
||||||
)
|
)
|
||||||
|
logger.info(f'Администратор удалён: {user_id} (удалил: {message.from_user.id})', log_type='ADMIN_MGMT')
|
||||||
else:
|
else:
|
||||||
text = "❌ <b>Ошибка удаления администратора</b>\n\nПопробуйте позже"
|
text = f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления администратора</b>\n\nПопробуйте позже'
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode='HTML')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка удаления администратора: {e}", log_type="ADMIN_MGMT")
|
logger.error(f'Ошибка удаления администратора: {e}', log_type='ADMIN_MGMT')
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка удаления</b>\n\nПопробуйте позже', parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
# ================= СПИСОК АДМИНИСТРАТОРОВ =================
|
# ================= СПИСОК АДМИНИСТРАТОРОВ =================
|
||||||
|
|
||||||
@router.callback_query(F.data == "listadmins:refresh")
|
@router.callback_query(F.data == 'listadmins:refresh')
|
||||||
@router.message(Command(*COMMANDS.get("listadmins", ["listadmins"]), prefix=settings.PREFIX, ignore_case=True),
|
@router.message(Command(*COMMANDS.get('listadmins', ['listadmins']), prefix=settings.PREFIX, ignore_case=True),
|
||||||
IsSuperAdmin())
|
IsSuperAdmin())
|
||||||
@log_action(action_name="LIST_ADMINS")
|
@log_action(action_name='LIST_ADMINS')
|
||||||
async def list_admins_cmd(update: Message | CallbackQuery) -> None:
|
async def list_admins_cmd(update: Message | CallbackQuery) -> None:
|
||||||
"""
|
|
||||||
Показывает список всех администраторов бота.
|
|
||||||
|
|
||||||
Доступно только владельцам бота (OWNER_ID).
|
|
||||||
|
|
||||||
Использование: /listadmins
|
|
||||||
"""
|
|
||||||
# Определяем тип update
|
|
||||||
if isinstance(update, CallbackQuery):
|
if isinstance(update, CallbackQuery):
|
||||||
message = update.message
|
message = update.message
|
||||||
is_callback = True
|
is_callback = True
|
||||||
@@ -256,179 +205,143 @@ async def list_admins_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
is_callback = False
|
is_callback = False
|
||||||
|
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Получаем всех админов из БД
|
|
||||||
db_admins = await manager.repo.get_admins()
|
db_admins = await manager.repo.get_admins()
|
||||||
|
|
||||||
# Получаем статистику
|
output = f'{tg_emoji("4960891456869893259")} <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n'
|
||||||
stats = await manager.get_stats()
|
|
||||||
|
|
||||||
# === ФОРМИРУЕМ ВЫВОД ===
|
# ВЛАДЕЛЬЦЫ
|
||||||
|
output += f'{tg_emoji("4963343509533754468")} <b>Владельцы бота</b> (полные права):\n'
|
||||||
output = "👥 <b>СПИСОК АДМИНИСТРАТОРОВ</b>\n\n"
|
|
||||||
|
|
||||||
# Владельцы (OWNER_ID)
|
|
||||||
output += "👑 <b>Владельцы бота</b> (полные права):\n"
|
|
||||||
for owner_id in settings.OWNER_ID:
|
for owner_id in settings.OWNER_ID:
|
||||||
output += f'├─ <a href="tg://user?id={owner_id}">{owner_id}</a>\n'
|
display_name = await get_user_display_name(owner_id)
|
||||||
output += "\n"
|
output += f'├─ <a href="tg://user?id={owner_id}">{display_name}</a>\n'
|
||||||
|
output += '\n'
|
||||||
|
|
||||||
# Администраторы из БД
|
# АДМИНИСТРАТОРЫ
|
||||||
if db_admins:
|
if db_admins:
|
||||||
output += f"⚙️ <b>Администраторы</b> ({len(db_admins)}):\n"
|
output += f'{tg_emoji("4961064956368782417")} <b>Администраторы</b> ({len(db_admins)}):\n'
|
||||||
|
|
||||||
for admin_id in sorted(db_admins):
|
for admin_id in sorted(db_admins):
|
||||||
output += f'├─ <a href="tg://user?id={admin_id}">{admin_id}</a>\n'
|
display_name = await get_user_display_name(admin_id)
|
||||||
|
output += f'├─ <a href="tg://user?id={admin_id}">{display_name}</a>\n'
|
||||||
output += "\n"
|
output += '\n'
|
||||||
output += "📋 <b>Права администраторов:</b>\n"
|
output += f'{tg_emoji("4961106084975608869")} <b>Права администраторов:</b>\n'
|
||||||
output += "├─ Управление банвордами\n"
|
output += '├─ Управление банвордами\n'
|
||||||
output += "├─ Просмотр статистики\n"
|
output += '├─ Просмотр статистики\n'
|
||||||
output += "├─ Активация режимов модерации\n"
|
output += '├─ Активация режимов модерации\n'
|
||||||
output += "└─ Все команды бота (кроме управления админами)\n\n"
|
output += '└─ Все команды бота\n\n'
|
||||||
else:
|
else:
|
||||||
output += "⚙️ <b>Администраторы:</b>\n"
|
output += f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
|
||||||
output += "└─ <i>Нет дополнительных администраторов</i>\n\n"
|
output += '└─ <i>Нет дополнительных администраторов</i>\n\n'
|
||||||
|
|
||||||
# Общая статистика
|
|
||||||
total_admins = len(settings.OWNER_ID) + len(db_admins)
|
total_admins = len(settings.OWNER_ID) + len(db_admins)
|
||||||
output += f"📊 <b>Итого:</b> {total_admins} администратор(ов)\n\n"
|
output += f'{tg_emoji("4961061266991875258")} <b>Итого:</b> {total_admins} администратор(ов)\n\n'
|
||||||
|
|
||||||
# Команды управления
|
output += f'{tg_emoji("4961027057577362562")} <b>Управление:</b>\n'
|
||||||
output += "🔧 <b>Управление:</b>\n"
|
output += '• <b>/adminhelp</b> — помощь по командам админов\n'
|
||||||
output += "• /addadmin <code>ID</code> — добавить админа\n"
|
output += '• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
|
||||||
output += "• /remadmin <code>ID</code> — удалить админа\n\n"
|
output += '• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
|
||||||
|
output += f'{tg_emoji("4961186405159011104")} <i>Только владельцы могут управлять администраторами</i>'
|
||||||
|
|
||||||
output += "💡 <i>Только владельцы могут управлять администраторами</i>"
|
|
||||||
|
|
||||||
# Клавиатура
|
|
||||||
keyboard = get_refresh_admins_kb()
|
keyboard = get_refresh_admins_kb()
|
||||||
|
|
||||||
# Отправка
|
|
||||||
if is_callback:
|
if is_callback:
|
||||||
await message.edit_text(
|
await message.edit_text(text=output, parse_mode='HTML', reply_markup=keyboard)
|
||||||
text=output,
|
await update.answer(f'{tg_emoji("4963010134172239128")} Список обновлён')
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=keyboard
|
|
||||||
)
|
|
||||||
await update.answer("✅ Список обновлён")
|
|
||||||
else:
|
else:
|
||||||
await message.answer(
|
await message.answer(text=output, parse_mode='HTML', reply_markup=keyboard)
|
||||||
text=output,
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=keyboard
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка получения списка администраторов: {e}", log_type="ADMIN_MGMT")
|
logger.error(f'Ошибка получения списка администраторов: {e}', log_type='ADMIN_MGMT')
|
||||||
|
error_text = f'{tg_emoji("4961187972822074653")} <b>Ошибка загрузки списка</b>\n\nПопробуйте позже'
|
||||||
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
|
|
||||||
|
|
||||||
if is_callback:
|
if is_callback:
|
||||||
await update.answer("❌ Ошибка загрузки", show_alert=True)
|
await update.answer(f'{tg_emoji("4961187972822074653")} Ошибка загрузки', show_alert=True)
|
||||||
else:
|
else:
|
||||||
await message.answer(error_text, parse_mode="HTML")
|
await message.answer(error_text, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
# ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK =================
|
# ================= ВСПОМОГАТЕЛЬНЫЕ CALLBACK =================
|
||||||
|
|
||||||
@router.callback_query(F.data == "admin:help_add")
|
@router.callback_query(F.data == 'admin:help_add')
|
||||||
async def admin_help_add_callback(callback: CallbackQuery) -> None:
|
async def admin_help_add_callback(callback: CallbackQuery) -> None:
|
||||||
"""Показывает помощь по добавлению админа"""
|
|
||||||
text = (
|
text = (
|
||||||
"➕ <b>Как добавить администратора?</b>\n\n"
|
f'{tg_emoji("4963469772982322370")} <b>Как добавить администратора?</b>\n\n'
|
||||||
"1️⃣ Узнайте Telegram ID пользователя\n"
|
f'{tg_emoji("4960889107522782272")} Узнайте Telegram ID пользователя\n'
|
||||||
" • Используйте бота @userinfobot\n"
|
' • Используйте команду <b>/id</b> или бота @userinfobot\n'
|
||||||
" • Или попросите пользователя написать /start\n\n"
|
f'{tg_emoji("4960889107522782272")} Выполните команду:\n'
|
||||||
"2️⃣ Выполните команду:\n"
|
' <code>/addadmin ID</code>\n\n'
|
||||||
" <code>/addadmin ID</code>\n\n"
|
'Пример:\n'
|
||||||
"Пример:\n"
|
'<code>/addadmin 123456789</code>'
|
||||||
"<code>/addadmin 123456789</code>"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
await callback.message.answer(text, parse_mode="HTML")
|
await callback.message.answer(text, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("adminhelp", ["adminhelp"]), prefix=settings.PREFIX, ignore_case=True),
|
@router.message(Command(*COMMANDS.get('adminhelp', ['adminhelp']), prefix=settings.PREFIX, ignore_case=True),
|
||||||
IsSuperAdmin())
|
IsSuperAdmin())
|
||||||
async def admin_help_cmd(message: Message) -> None:
|
async def admin_help_cmd(message: Message) -> None:
|
||||||
"""
|
|
||||||
Показывает подробную справку по управлению администраторами.
|
|
||||||
|
|
||||||
Использование: /adminhelp
|
|
||||||
"""
|
|
||||||
text = (
|
text = (
|
||||||
"👥 <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n"
|
f'{tg_emoji("4960891456869893259")} <b>УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ</b>\n\n'
|
||||||
"🔐 <b>Уровни доступа:</b>\n\n"
|
f'{tg_emoji("4963401727815451692")} <b>Уровни доступа:</b>\n\n'
|
||||||
"👑 <b>Владельцы</b> (OWNER_ID):\n"
|
f'{tg_emoji("4963343509533754468")} <b>Владельцы</b> (OWNER_ID):\n'
|
||||||
"├─ Все права администратора\n"
|
'├─ Все права администратора\n'
|
||||||
"├─ Управление другими админами\n"
|
'├─ Управление другими админами\n'
|
||||||
"└─ Указываются в конфигурации\n\n"
|
'└─ Указываются в конфигурации\n\n'
|
||||||
"⚙️ <b>Администраторы:</b>\n"
|
f'{tg_emoji("4961064956368782417")} <b>Администраторы:</b>\n'
|
||||||
"├─ Управление банвордами\n"
|
'├─ Управление банвордами\n'
|
||||||
"├─ Просмотр статистики\n"
|
'├─ Просмотр статистики\n'
|
||||||
"├─ Активация режимов модерации\n"
|
'├─ Активация режимов модерации\n'
|
||||||
"└─ НЕ могут управлять админами\n\n"
|
'└─ Не могут управлять админами\n\n'
|
||||||
"📝 <b>Команды:</b>\n"
|
f'{tg_emoji("4963241130398319816")} <b>Команды:</b>\n'
|
||||||
"• /listadmins — список всех админов\n"
|
'• <b>/adminhelp</b> — помощь по командам админов\n'
|
||||||
"• /addadmin <code>ID</code> — добавить админа\n"
|
'• <b>/listadmins</b> — список всех админов\n'
|
||||||
"• /remadmin <code>ID</code> — удалить админа\n\n"
|
'• <code>/addadmin</code> <code>ID</code> — добавить админа\n'
|
||||||
"💡 <b>Как узнать ID пользователя?</b>\n"
|
'• <code>/remadmin</code> <code>ID</code> — удалить админа\n\n'
|
||||||
"• Используйте бота @userinfobot\n"
|
f'{tg_emoji("4961186405159011104")} <b>Как узнать ID пользователя?</b>\n'
|
||||||
"• Попросите пользователя написать боту\n"
|
'• Используйте команду <b>/id</b> или бота @userinfobot\n'
|
||||||
"• ID отображается в логах бота\n\n"
|
'• Или попросите пользователя написать боту\n'
|
||||||
"⚠️ <b>Важно:</b>\n"
|
'• ID отображается в логах бота\n\n'
|
||||||
"├─ Нельзя удалить владельца\n"
|
f'{tg_emoji("4963024861615096794")} <b>Важно:</b>\n'
|
||||||
"├─ Нельзя удалить самого себя\n"
|
'├─ Нельзя удалить владельца\n'
|
||||||
"└─ Все действия логируются"
|
'├─ Нельзя удалить самого себя\n'
|
||||||
|
'└─ Все действия логируются'
|
||||||
)
|
)
|
||||||
|
await message.answer(text, parse_mode='HTML')
|
||||||
await message.answer(text, parse_mode="HTML")
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("checkadmin", ["checkadmin"]), prefix=settings.PREFIX, ignore_case=True),
|
@router.message(Command(*COMMANDS.get('checkadmin', ['checkadmin']), prefix=settings.PREFIX, ignore_case=True),
|
||||||
IsSuperAdmin())
|
IsSuperAdmin())
|
||||||
@log_action(action_name="CHECK_ADMIN")
|
@log_action(action_name='CHECK_ADMIN')
|
||||||
async def check_admin_cmd(message: Message) -> None:
|
async def check_admin_cmd(message: Message) -> None:
|
||||||
"""
|
success, result = parse_user_id(message.text, 'checkadmin')
|
||||||
Проверяет, является ли пользователь администратором.
|
|
||||||
|
|
||||||
Использование: /checkadmin <ID>
|
|
||||||
"""
|
|
||||||
success, result = parse_user_id(message.text, "checkadmin")
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
await message.answer(result, parse_mode="HTML")
|
await message.answer(result, parse_mode='HTML')
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id = result
|
user_id = result
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Проверяем статус
|
|
||||||
is_owner = user_id in settings.OWNER_ID
|
is_owner = user_id in settings.OWNER_ID
|
||||||
is_db_admin = await manager.is_admin(user_id)
|
is_db_admin = await manager.is_admin(user_id)
|
||||||
|
|
||||||
text = f"🔍 <b>Проверка пользователя</b>\n\n"
|
text = f'{tg_emoji("4961092195051373778")} <b>Проверка пользователя</b>\n\n'
|
||||||
text += f"👤 ID: <code>{user_id}</code>\n\n"
|
text += f'{tg_emoji("4961064956368782417")} ID: <code>{user_id}</code>\n\n'
|
||||||
|
|
||||||
if is_owner:
|
if is_owner:
|
||||||
text += "👑 Статус: <b>Владелец бота</b>\n"
|
text += f'{tg_emoji("4963343509533754468")} Статус: <b>Владелец бота</b>\n'
|
||||||
text += "✅ Полные права администратора\n"
|
text += f'{tg_emoji("4963010134172239128")} Полные права администратора\n'
|
||||||
text += "✅ Может управлять админами"
|
text += f'{tg_emoji("4963010134172239128")} Может управлять админами'
|
||||||
elif is_db_admin:
|
elif is_db_admin:
|
||||||
text += "⚙️ Статус: <b>Администратор</b>\n"
|
text += f'{tg_emoji("4961064956368782417")} Статус: <b>Администратор</b>\n'
|
||||||
text += "✅ Доступ к командам бота\n"
|
text += f'{tg_emoji("4963010134172239128")} Доступ к командам бота\n'
|
||||||
text += "❌ Не может управлять админами"
|
text += f'{tg_emoji("4961187972822074653")} Не может управлять админами'
|
||||||
else:
|
else:
|
||||||
text += "👤 Статус: <b>Обычный пользователь</b>\n"
|
text += f'{tg_emoji("4961064956368782417")} Статус: <b>Обычный пользователь</b>\n'
|
||||||
text += "❌ Нет прав администратора\n\n"
|
text += f'{tg_emoji("4961187972822074653")} Нет прав администратора\n\n'
|
||||||
text += f"Добавить в админы: <code>/addadmin {user_id}</code>"
|
text += f'Добавить в админы: <code>/addadmin {user_id}</code>'
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode='HTML')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка проверки администратора: {e}", log_type="ADMIN_MGMT")
|
logger.error(f'Ошибка проверки администратора: {e}', log_type='ADMIN_MGMT')
|
||||||
await message.answer("❌ <b>Ошибка проверки</b>", parse_mode="HTML")
|
await message.answer(f'{tg_emoji("4961187972822074653")} <b>Ошибка проверки</b>', parse_mode='HTML')
|
||||||
|
|||||||
330
bot/handlers/commands/users/bot_settings.py
Normal file
330
bot/handlers/commands/users/bot_settings.py
Normal 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("✅ Настройки отменены")
|
||||||
48
bot/handlers/commands/users/cancel.py
Normal file
48
bot/handlers/commands/users/cancel.py
Normal 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("✅ Действие отменено")
|
||||||
@@ -14,23 +14,28 @@ __all__ = ("router",)
|
|||||||
|
|
||||||
router: Router = Router(name="emoji_extractor_router")
|
router: Router = Router(name="emoji_extractor_router")
|
||||||
|
|
||||||
|
MAX_MSG_LEN = 3800 # Безопасный лимит (4096 - запас)
|
||||||
|
SEPARATOR = "\n" + "─" * 30 + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||||
|
|
||||||
|
def _utf16_slice(text: str, offset: int, length: int) -> str:
|
||||||
|
"""
|
||||||
|
Корректно извлекает подстроку с учётом UTF-16 смещений Telegram API.
|
||||||
|
Telegram передаёт offset/length в UTF-16 code units, а не в Unicode codepoints.
|
||||||
|
"""
|
||||||
|
encoded = text.encode("utf-16-le")
|
||||||
|
return encoded[offset * 2 : (offset + length) * 2].decode("utf-16-le")
|
||||||
|
|
||||||
|
|
||||||
def extract_custom_emojis(message: Message) -> list[dict]:
|
def extract_custom_emojis(message: Message) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Извлекает все кастомные эмодзи из сообщения.
|
Извлекает все кастомные эмодзи из сообщения (текст + подпись).
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Сообщение для анализа
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список словарей с информацией об эмодзи
|
Список словарей: {"char": str, "id": str, "offset": int}
|
||||||
"""
|
"""
|
||||||
if not message.entities and not message.caption_entities:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Определяем текст и entities
|
|
||||||
text = message.text or message.caption
|
text = message.text or message.caption
|
||||||
entities = message.entities or message.caption_entities
|
entities = message.entities or message.caption_entities
|
||||||
|
|
||||||
@@ -38,37 +43,23 @@ def extract_custom_emojis(message: Message) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
custom_emojis = []
|
custom_emojis = []
|
||||||
|
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
if entity.type == "custom_emoji":
|
if entity.type == "custom_emoji":
|
||||||
# Извлекаем символ эмодзи
|
emoji_char = _utf16_slice(text, entity.offset, entity.length)
|
||||||
emoji_char = text[entity.offset:entity.offset + entity.length]
|
|
||||||
|
|
||||||
custom_emojis.append({
|
custom_emojis.append({
|
||||||
"char": emoji_char,
|
"char": emoji_char,
|
||||||
"id": entity.custom_emoji_id,
|
"id": entity.custom_emoji_id,
|
||||||
"offset": entity.offset
|
"offset": entity.offset,
|
||||||
})
|
})
|
||||||
|
|
||||||
return custom_emojis
|
return custom_emojis
|
||||||
|
|
||||||
|
|
||||||
def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
|
def format_emoji_html(emoji_char: str, emoji_id: str) -> str:
|
||||||
"""
|
|
||||||
Форматирует эмодзи в HTML-тег.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
emoji_char: Символ эмодзи (fallback)
|
|
||||||
emoji_id: ID кастомного эмодзи
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HTML-строка
|
|
||||||
"""
|
|
||||||
return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
|
return f'<tg-emoji emoji-id="{emoji_id}">{emoji_char}</tg-emoji>'
|
||||||
|
|
||||||
|
|
||||||
def escape_html(text: str) -> str:
|
def escape_html(text: str) -> str:
|
||||||
"""Экранирует HTML символы"""
|
|
||||||
return (
|
return (
|
||||||
text.replace("&", "&")
|
text.replace("&", "&")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
@@ -76,6 +67,52 @@ def escape_html(text: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_emoji_block(idx: int, emoji_data: dict, is_last: bool) -> str:
|
||||||
|
"""Формирует текстовый блок для одного эмодзи."""
|
||||||
|
emoji_char = emoji_data["char"]
|
||||||
|
emoji_id = emoji_data["id"]
|
||||||
|
html_code = format_emoji_html(emoji_char, emoji_id)
|
||||||
|
html_escaped = escape_html(html_code)
|
||||||
|
|
||||||
|
block = (
|
||||||
|
f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
|
||||||
|
f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
|
||||||
|
f"📝 <b>HTML-код:</b>\n"
|
||||||
|
f"<code>{html_escaped}</code>\n\n"
|
||||||
|
f"🎨 <b>Превью:</b> {html_code}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_last:
|
||||||
|
block += SEPARATOR
|
||||||
|
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def build_pages(custom_emojis: list[dict]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Разбивает список эмодзи на страницы, каждая не длиннее MAX_MSG_LEN.
|
||||||
|
Возвращает список готовых HTML-строк для отправки.
|
||||||
|
"""
|
||||||
|
total = len(custom_emojis)
|
||||||
|
pages: list[str] = []
|
||||||
|
current_page = ""
|
||||||
|
|
||||||
|
for idx, emoji_data in enumerate(custom_emojis, 1):
|
||||||
|
is_last = (idx == total)
|
||||||
|
block = _build_emoji_block(idx, emoji_data, is_last)
|
||||||
|
|
||||||
|
if current_page and len(current_page) + len(block) > MAX_MSG_LEN:
|
||||||
|
pages.append(current_page)
|
||||||
|
current_page = block
|
||||||
|
else:
|
||||||
|
current_page += block
|
||||||
|
|
||||||
|
if current_page:
|
||||||
|
pages.append(current_page)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
# ================= КОМАНДА /EMOJI =================
|
# ================= КОМАНДА /EMOJI =================
|
||||||
|
|
||||||
@router.message(
|
@router.message(
|
||||||
@@ -83,14 +120,6 @@ def escape_html(text: str) -> str:
|
|||||||
IsAdmin()
|
IsAdmin()
|
||||||
)
|
)
|
||||||
async def emoji_extractor_cmd(message: Message) -> None:
|
async def emoji_extractor_cmd(message: Message) -> None:
|
||||||
"""
|
|
||||||
Извлекает кастомные эмодзи из сообщения.
|
|
||||||
|
|
||||||
Доступно только администраторам.
|
|
||||||
|
|
||||||
Использование: /emoji (в ответ на сообщение)
|
|
||||||
"""
|
|
||||||
# Проверяем, что команда в ответ на сообщение
|
|
||||||
if not message.reply_to_message:
|
if not message.reply_to_message:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
||||||
@@ -98,66 +127,57 @@ async def emoji_extractor_cmd(message: Message) -> None:
|
|||||||
"1. Ответьте на сообщение с премиум эмодзи\n"
|
"1. Ответьте на сообщение с премиум эмодзи\n"
|
||||||
"2. Напишите <code>/emoji</code>\n\n"
|
"2. Напишите <code>/emoji</code>\n\n"
|
||||||
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
|
"💡 <i>Бот извлечёт все кастомные эмодзи и покажет HTML-код</i>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
replied_message = message.reply_to_message
|
replied_message = message.reply_to_message
|
||||||
|
|
||||||
# Извлекаем кастомные эмодзи
|
|
||||||
custom_emojis = extract_custom_emojis(replied_message)
|
custom_emojis = extract_custom_emojis(replied_message)
|
||||||
|
|
||||||
if not custom_emojis:
|
if not custom_emojis:
|
||||||
# Нет кастомных эмодзи
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
|
"⚠️ <b>Кастомные эмодзи не найдены</b>\n\n"
|
||||||
"В этом сообщении нет премиум эмодзи.\n\n"
|
"В этом сообщении нет премиум эмодзи.\n\n"
|
||||||
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
|
"💡 <i>Попробуйте ответить на сообщение с анимированными эмодзи</i>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# === ФОРМИРУЕМ ОТВЕТ ===
|
total = len(custom_emojis)
|
||||||
|
pages = build_pages(custom_emojis)
|
||||||
|
total_pages = len(pages)
|
||||||
|
|
||||||
output = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {len(custom_emojis)}</b>\n\n"
|
|
||||||
|
|
||||||
for idx, emoji_data in enumerate(custom_emojis, 1):
|
|
||||||
emoji_char = emoji_data["char"]
|
|
||||||
emoji_id = emoji_data["id"]
|
|
||||||
|
|
||||||
output += f"<b>{idx}.</b> Эмодзи: {emoji_char}\n"
|
|
||||||
output += f"📋 <b>ID:</b> <code>{emoji_id}</code>\n\n"
|
|
||||||
|
|
||||||
# HTML-код (экранированный для отображения)
|
|
||||||
html_code = format_emoji_html(emoji_char, emoji_id)
|
|
||||||
html_escaped = escape_html(html_code)
|
|
||||||
|
|
||||||
output += f"📝 <b>HTML-код:</b>\n"
|
|
||||||
output += f"<code>{html_escaped}</code>\n\n"
|
|
||||||
|
|
||||||
# Пример использования
|
|
||||||
output += f"🎨 <b>Превью:</b> {html_code}\n"
|
|
||||||
|
|
||||||
if idx < len(custom_emojis):
|
|
||||||
output += "\n" + "─" * 30 + "\n\n"
|
|
||||||
|
|
||||||
output += "💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
|
|
||||||
|
|
||||||
# Создаём клавиатуру
|
|
||||||
ikb = InlineKeyboardBuilder()
|
ikb = InlineKeyboardBuilder()
|
||||||
ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
|
ikb.button(text="✖️ Закрыть", callback_data="emoji_close")
|
||||||
|
|
||||||
# Отправляем
|
|
||||||
try:
|
try:
|
||||||
|
for page_num, page_content in enumerate(pages, 1):
|
||||||
|
# Заголовок только на первой странице
|
||||||
|
if page_num == 1:
|
||||||
|
header = f"✨ <b>НАЙДЕНО ЭМОДЗИ: {total}</b>\n\n"
|
||||||
|
else:
|
||||||
|
header = f"✨ <b>ПРОДОЛЖЕНИЕ ({page_num}/{total_pages})</b>\n\n"
|
||||||
|
|
||||||
|
# Подвал только на последней странице
|
||||||
|
footer = (
|
||||||
|
"\n\n💡 <i>Скопируйте HTML-код и используйте в своих сообщениях</i>"
|
||||||
|
if page_num == total_pages
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопка закрытия только на последней странице
|
||||||
|
markup = ikb.as_markup() if page_num == total_pages else None
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=output,
|
text=header + page_content + footer,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=ikb.as_markup()
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Извлечено {len(custom_emojis)} кастомных эмодзи админом {message.from_user.id}",
|
f"Извлечено {total} кастомных эмодзи ({total_pages} стр.) "
|
||||||
log_type="EMOJI_EXTRACT"
|
f"админом {message.from_user.id}",
|
||||||
|
log_type="EMOJI_EXTRACT",
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -165,7 +185,7 @@ async def emoji_extractor_cmd(message: Message) -> None:
|
|||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Ошибка извлечения эмодзи</b>\n\n"
|
"❌ <b>Ошибка извлечения эмодзи</b>\n\n"
|
||||||
"Попробуйте позже или обратитесь к разработчику.",
|
"Попробуйте позже или обратитесь к разработчику.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +193,6 @@ async def emoji_extractor_cmd(message: Message) -> None:
|
|||||||
|
|
||||||
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
|
@router.callback_query(lambda c: c.data == "emoji_close", IsAdmin())
|
||||||
async def emoji_close_callback(callback) -> None:
|
async def emoji_close_callback(callback) -> None:
|
||||||
"""Закрывает сообщение с эмодзи"""
|
|
||||||
try:
|
try:
|
||||||
await callback.message.delete()
|
await callback.message.delete()
|
||||||
await callback.answer("✅ Закрыто")
|
await callback.answer("✅ Закрыто")
|
||||||
@@ -189,9 +208,6 @@ async def emoji_close_callback(callback) -> None:
|
|||||||
IsAdmin()
|
IsAdmin()
|
||||||
)
|
)
|
||||||
async def emoji_help_cmd(message: Message) -> None:
|
async def emoji_help_cmd(message: Message) -> None:
|
||||||
"""
|
|
||||||
Справка по работе с кастомными эмодзи.
|
|
||||||
"""
|
|
||||||
text = (
|
text = (
|
||||||
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
|
"🎨 <b>РАБОТА С КАСТОМНЫМИ ЭМОДЗИ</b>\n\n"
|
||||||
"📝 <b>Команда /emoji</b>\n"
|
"📝 <b>Команда /emoji</b>\n"
|
||||||
@@ -201,15 +217,11 @@ async def emoji_help_cmd(message: Message) -> None:
|
|||||||
"2️⃣ Напишите <code>/emoji</code>\n"
|
"2️⃣ Напишите <code>/emoji</code>\n"
|
||||||
"3️⃣ Скопируйте HTML-код\n\n"
|
"3️⃣ Скопируйте HTML-код\n\n"
|
||||||
"💻 <b>Формат HTML-кода:</b>\n"
|
"💻 <b>Формат HTML-кода:</b>\n"
|
||||||
"<code><tg-emoji emoji-id=\"ID\">fallback</tg-emoji></code>\n\n"
|
'<code><tg-emoji emoji-id="ID">fallback</tg-emoji></code>\n\n'
|
||||||
"📌 <b>Пример использования в коде:</b>\n"
|
|
||||||
"<code>text = 'Привет <tg-emoji emoji-id=\"5368324170671202286\">👍</tg-emoji>'\n"
|
|
||||||
"await message.answer(text, parse_mode=\"HTML\")</code>\n\n"
|
|
||||||
"⚠️ <b>Важно:</b>\n"
|
"⚠️ <b>Важно:</b>\n"
|
||||||
"├─ Используйте <code>parse_mode=\"HTML\"</code>\n"
|
'├─ Используйте <code>parse_mode="HTML"</code>\n'
|
||||||
"├─ Пользователи без Premium видят fallback\n"
|
"├─ Пользователи без Premium видят fallback\n"
|
||||||
"└─ Работает только с кастомными эмодзи\n\n"
|
"└─ Работает только с кастомными эмодзи\n\n"
|
||||||
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
|
"💡 <i>Попробуйте отправить эмодзи и ответить командой /emoji</i>"
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from configs import settings, COMMANDS
|
from configs import settings, COMMANDS
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
|
|
||||||
__all__ = ("router",)
|
__all__ = ('router',)
|
||||||
|
|
||||||
router: Router = Router(name="user_id_router")
|
router: Router = Router(name='user_id_router')
|
||||||
|
|
||||||
|
|
||||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||||
@@ -19,13 +19,13 @@ router: Router = Router(name="user_id_router")
|
|||||||
def get_close_keyboard():
|
def get_close_keyboard():
|
||||||
"""Создаёт клавиатуру с кнопкой закрытия"""
|
"""Создаёт клавиатуру с кнопкой закрытия"""
|
||||||
ikb = InlineKeyboardBuilder()
|
ikb = InlineKeyboardBuilder()
|
||||||
ikb.button(text="✖️ Закрыть", callback_data="id_close")
|
ikb.button(text='✖️ Закрыть', callback_data='id_close')
|
||||||
return ikb.as_markup()
|
return ikb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
# ================= КОМАНДА /ID =================
|
# ================= КОМАНДА /ID =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("id", ["id"]), prefix=settings.PREFIX, ignore_case=True))
|
@router.message(Command(*COMMANDS.get('id', ['id']), prefix=settings.PREFIX, ignore_case=True))
|
||||||
async def id_cmd(message: Message) -> None:
|
async def id_cmd(message: Message) -> None:
|
||||||
"""
|
"""
|
||||||
Показывает информацию о вашем Telegram аккаунте.
|
Показывает информацию о вашем Telegram аккаунте.
|
||||||
@@ -37,12 +37,12 @@ async def id_cmd(message: Message) -> None:
|
|||||||
user = message.from_user
|
user = message.from_user
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
await message.answer("❌ Не удалось получить информацию о пользователе")
|
await message.answer('❌ Не удалось получить информацию о пользователе')
|
||||||
return
|
return
|
||||||
|
|
||||||
# === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
|
# === ФОРМИРУЕМ ИНФОРМАЦИЮ ===
|
||||||
|
|
||||||
output = "👤 <b>ИНФОРМАЦИЯ О ВАС</b>\n\n"
|
output = '<tg-emoji emoji-id="4961064956368782417">💠</tg-emoji> <b>ИНФОРМАЦИЯ О ВАС</b>\n\n'
|
||||||
|
|
||||||
# Имя
|
# Имя
|
||||||
full_name_parts = []
|
full_name_parts = []
|
||||||
@@ -51,28 +51,28 @@ async def id_cmd(message: Message) -> None:
|
|||||||
if user.last_name:
|
if user.last_name:
|
||||||
full_name_parts.append(user.last_name)
|
full_name_parts.append(user.last_name)
|
||||||
|
|
||||||
full_name = " ".join(full_name_parts) if full_name_parts else "Не указано"
|
full_name = ' '.join(full_name_parts) if full_name_parts else 'Не указано'
|
||||||
output += f"📝 <b>Имя:</b> {full_name}\n"
|
output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Имя:</b> {full_name}\n'
|
||||||
|
|
||||||
# Username
|
# Username
|
||||||
if user.username:
|
if user.username:
|
||||||
output += f"🔗 <b>Username:</b> @{user.username}\n"
|
output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{user.username}\n'
|
||||||
else:
|
else:
|
||||||
output += f"🔗 <b>Username:</b> <i>не установлен</i>\n"
|
output += '<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> <i>не установлен</i>\n'
|
||||||
|
|
||||||
# ID
|
# ID
|
||||||
output += f"🆔 <b>ID:</b> <code>{user.id}</code>\n\n"
|
output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>ID:</b> <code>{user.id}</code>\n\n'
|
||||||
|
|
||||||
# Тип аккаунта
|
# Тип аккаунта
|
||||||
if user.is_bot:
|
if user.is_bot:
|
||||||
output += f"🤖 <b>Тип:</b> Бот\n"
|
output += '🤖 <b>Тип:</b> Бот\n'
|
||||||
elif user.is_premium:
|
elif getattr(user, 'is_premium', False):
|
||||||
output += f"⭐️ <b>Тип:</b> Premium пользователь\n"
|
output += '<tg-emoji emoji-id="4961075019477156700">💠</tg-emoji> <b>Тип:</b> Premium пользователь\n'
|
||||||
else:
|
else:
|
||||||
output += f"👥 <b>Тип:</b> Обычный пользователь\n"
|
output += '👥 <b>Тип:</b> Обычный пользователь\n'
|
||||||
|
|
||||||
# Дополнительная информация
|
# Дополнительная информация
|
||||||
output += "\n📊 <b>Дополнительно:</b>\n"
|
output += '\n<tg-emoji emoji-id="4961141003059725568">💠</tg-emoji> <b>Дополнительно:</b>\n'
|
||||||
|
|
||||||
# Язык
|
# Язык
|
||||||
if user.language_code:
|
if user.language_code:
|
||||||
@@ -86,36 +86,33 @@ async def id_cmd(message: Message) -> None:
|
|||||||
'it': '🇮🇹 Italiano',
|
'it': '🇮🇹 Italiano',
|
||||||
'pt': '🇵🇹 Português',
|
'pt': '🇵🇹 Português',
|
||||||
}
|
}
|
||||||
language = language_names.get(user.language_code, f"🌐 {user.language_code.upper()}")
|
language = language_names.get(user.language_code, f'🌐 {user.language_code.upper()}')
|
||||||
output += f"├─ Язык: {language}\n"
|
output += f'├─ Язык: {language}\n'
|
||||||
|
|
||||||
# Информация о чате
|
# Информация о чате
|
||||||
if message.chat.type == "private":
|
if message.chat.type == 'private':
|
||||||
output += f"├─ Чат: 💬 Личные сообщения\n"
|
output += '├─ Чат: 💬 Личные сообщения\n'
|
||||||
else:
|
else:
|
||||||
chat_title = message.chat.title or "Без названия"
|
chat_title = message.chat.title or 'Без названия'
|
||||||
chat_types = {
|
chat_types = {
|
||||||
"group": "👥 Группа",
|
'group': '👥 Группа',
|
||||||
"supergroup": "👥 Супергруппа",
|
'supergroup': '👥 Супергруппа',
|
||||||
"channel": "📢 Канал"
|
'channel': '📢 Канал'
|
||||||
}
|
}
|
||||||
chat_type = chat_types.get(message.chat.type, "💬 Чат")
|
chat_type = chat_types.get(message.chat.type, '💬 Чат')
|
||||||
output += f"├─ Чат: {chat_type}\n"
|
output += f'├─ Чат: {chat_type}\n'
|
||||||
output += f"├─ Название: {chat_title}\n"
|
output += f'├─ Название: {chat_title}\n'
|
||||||
output += f"├─ Chat ID: <code>{message.chat.id}</code>\n"
|
output += f'├─ Chat ID: <code>{message.chat.id}</code>\n'
|
||||||
|
|
||||||
# Получаем количество участников (только для групп)
|
# Получаем количество участников (только для групп)
|
||||||
try:
|
try:
|
||||||
member_count = await message.bot.get_chat_member_count(message.chat.id)
|
member_count = await message.bot.get_chat_member_count(message.chat.id)
|
||||||
output += f"├─ Участников: {member_count}\n"
|
output += f'├─ Участников: {member_count}\n'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
|
logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
|
||||||
|
|
||||||
# Message ID
|
# Message ID
|
||||||
output += f"└─ Message ID: <code>{message.message_id}</code>\n\n"
|
output += f'└─ Message ID: <code>{message.message_id}</code>\n\n'
|
||||||
|
|
||||||
# Подсказка
|
|
||||||
output += "💡 <i>Эту информацию видите только вы</i>"
|
|
||||||
|
|
||||||
# Клавиатура
|
# Клавиатура
|
||||||
keyboard = get_close_keyboard()
|
keyboard = get_close_keyboard()
|
||||||
@@ -124,33 +121,33 @@ async def id_cmd(message: Message) -> None:
|
|||||||
try:
|
try:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=output,
|
text=output,
|
||||||
parse_mode="HTML",
|
parse_mode='HTML',
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"Команда /id от пользователя {user.id}", log_type="USER_ID")
|
logger.debug(f'Команда /id от пользователя {user.id}', log_type='USER_ID')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки информации о пользователе: {e}", log_type="ERROR")
|
logger.error(f'Ошибка отправки информации о пользователе: {e}', log_type='ERROR')
|
||||||
await message.answer("❌ Произошла ошибка при получении информации")
|
await message.answer('❌ Произошла ошибка при получении информации')
|
||||||
|
|
||||||
|
|
||||||
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
|
# ================= ОБРАБОТЧИК КНОПКИ ЗАКРЫТИЯ =================
|
||||||
|
|
||||||
@router.callback_query(F.data == "id_close")
|
@router.callback_query(F.data == 'id_close')
|
||||||
async def id_close_callback(callback: CallbackQuery) -> None:
|
async def id_close_callback(callback: CallbackQuery) -> None:
|
||||||
"""Закрывает (удаляет) сообщение с информацией"""
|
"""Закрывает (удаляет) сообщение с информацией"""
|
||||||
try:
|
try:
|
||||||
await callback.message.delete()
|
await callback.message.delete()
|
||||||
await callback.answer("✅ Закрыто")
|
await callback.answer('✅ Закрыто')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка удаления сообщения ID: {e}", log_type="ERROR")
|
logger.error(f'Ошибка удаления сообщения ID: {e}', log_type='ERROR')
|
||||||
await callback.answer("❌ Не удалось удалить сообщение", show_alert=True)
|
await callback.answer('❌ Не удалось удалить сообщение', show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) =================
|
# ================= КОМАНДА /MYID (АЛЬТЕРНАТИВА) =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("myid", ["myid"]), prefix=settings.PREFIX, ignore_case=True))
|
@router.message(Command(*COMMANDS.get('myid', ['myid']), prefix=settings.PREFIX, ignore_case=True))
|
||||||
async def myid_cmd(message: Message) -> None:
|
async def myid_cmd(message: Message) -> None:
|
||||||
"""
|
"""
|
||||||
Быстрый просмотр вашего ID.
|
Быстрый просмотр вашего ID.
|
||||||
@@ -160,21 +157,21 @@ async def myid_cmd(message: Message) -> None:
|
|||||||
user = message.from_user
|
user = message.from_user
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
await message.answer("❌ Не удалось получить ID")
|
await message.answer('❌ Не удалось получить ID')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Короткий ответ
|
# Короткий ответ
|
||||||
text = f"🆔 Ваш ID: <code>{user.id}</code>"
|
text = f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> Ваш ID: <code>{user.id}</code>'
|
||||||
|
|
||||||
if user.username:
|
if user.username:
|
||||||
text += f"\n🔗 Username: @{user.username}"
|
text += f'\n<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> Username: @{user.username}'
|
||||||
|
|
||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode='HTML')
|
||||||
|
|
||||||
|
|
||||||
# ================= КОМАНДА /CHATID =================
|
# ================= КОМАНДА /CHATID =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("chatid", ["chatid"]), prefix=settings.PREFIX, ignore_case=True))
|
@router.message(Command(*COMMANDS.get('chatid', ['chatid']), prefix=settings.PREFIX, ignore_case=True))
|
||||||
async def chatid_cmd(message: Message) -> None:
|
async def chatid_cmd(message: Message) -> None:
|
||||||
"""
|
"""
|
||||||
Показывает ID текущего чата.
|
Показывает ID текущего чата.
|
||||||
@@ -183,39 +180,39 @@ async def chatid_cmd(message: Message) -> None:
|
|||||||
"""
|
"""
|
||||||
chat = message.chat
|
chat = message.chat
|
||||||
|
|
||||||
output = "💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n"
|
output = '💬 <b>ИНФОРМАЦИЯ О ЧАТЕ</b>\n\n'
|
||||||
|
|
||||||
# Тип чата
|
# Тип чата
|
||||||
chat_types = {
|
chat_types = {
|
||||||
"private": "💬 Личные сообщения",
|
'private': '💬 Личные сообщения',
|
||||||
"group": "👥 Группа",
|
'group': '👥 Группа',
|
||||||
"supergroup": "👥 Супергруппа",
|
'supergroup': '👥 Супергруппа',
|
||||||
"channel": "📢 Канал"
|
'channel': '📢 Канал'
|
||||||
}
|
}
|
||||||
chat_type = chat_types.get(chat.type, "💬 Чат")
|
chat_type = chat_types.get(chat.type, '💬 Чат')
|
||||||
|
|
||||||
output += f"📝 <b>Тип:</b> {chat_type}\n"
|
output += f'<tg-emoji emoji-id="4960791319707387164">💠</tg-emoji> <b>Тип:</b> {chat_type}\n'
|
||||||
|
|
||||||
if chat.title:
|
if chat.title:
|
||||||
output += f"📌 <b>Название:</b> {chat.title}\n"
|
output += f'📌 <b>Название:</b> {chat.title}\n'
|
||||||
|
|
||||||
if chat.username:
|
if chat.username:
|
||||||
output += f"🔗 <b>Username:</b> @{chat.username}\n"
|
output += f'<tg-emoji emoji-id="4961200307968148582">💠</tg-emoji> <b>Username:</b> @{chat.username}\n'
|
||||||
|
|
||||||
output += f"🆔 <b>Chat ID:</b> <code>{chat.id}</code>\n"
|
output += f'<tg-emoji emoji-id="4961121396534019447">💠</tg-emoji> <b>Chat ID:</b> <code>{chat.id}</code>\n'
|
||||||
|
|
||||||
# Дополнительная информация для групп
|
# Дополнительная информация для групп
|
||||||
if chat.type in ["group", "supergroup"]:
|
if chat.type in ['group', 'supergroup']:
|
||||||
try:
|
try:
|
||||||
member_count = await message.bot.get_chat_member_count(chat.id)
|
member_count = await message.bot.get_chat_member_count(chat.id)
|
||||||
output += f"👥 <b>Участников:</b> {member_count}\n"
|
output += f'👥 <b>Участников:</b> {member_count}\n'
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Не удалось получить количество участников: {e}", log_type="USER_ID")
|
logger.debug(f'Не удалось получить количество участников: {e}', log_type='USER_ID')
|
||||||
|
|
||||||
keyboard = get_close_keyboard()
|
keyboard = get_close_keyboard()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=output,
|
text=output,
|
||||||
parse_mode="HTML",
|
parse_mode='HTML',
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from aiogram import Router, F
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
from bot.filters.admin import IsAdmin
|
from bot.filters.admin import IsAdmin
|
||||||
from configs import settings, COMMANDS
|
from configs import settings, COMMANDS
|
||||||
@@ -123,7 +124,7 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
|
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
|
||||||
if conflict_words or conflict_lemmas:
|
if conflict_words or conflict_lemmas:
|
||||||
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
|
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
|
||||||
output += "<i>(работают только в режиме /stopconflict)</i>\n\n"
|
output += "<i>(работают только в режиме <code>/stopconflict</code> <code>время</code>)</i>\n\n"
|
||||||
|
|
||||||
if conflict_words:
|
if conflict_words:
|
||||||
output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n"
|
output += f"📝 <b>Конфликтные слова</b> ({len(conflict_words)}):\n"
|
||||||
@@ -188,9 +189,7 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
"""
|
"""
|
||||||
Обработчик команды /listwords.
|
Обработчик команды /listwords.
|
||||||
Отображает список всех правил модерации с разбивкой по категориям.
|
Отображает список всех правил модерации с разбивкой по категориям.
|
||||||
|
|
||||||
Доступно только администраторам.
|
Доступно только администраторам.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
update: Message или CallbackQuery
|
update: Message или CallbackQuery
|
||||||
"""
|
"""
|
||||||
@@ -214,12 +213,18 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
keyboard = get_refresh_kb(page)
|
keyboard = get_refresh_kb(page)
|
||||||
|
|
||||||
if is_callback:
|
if is_callback:
|
||||||
|
try:
|
||||||
await message.edit_text(
|
await message.edit_text(
|
||||||
text=text,
|
text=text,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
await update.answer("✅ Список обновлён")
|
await update.answer("✅ Список обновлён")
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if 'message is not modified' in str(e).lower():
|
||||||
|
await update.answer('✅ Список уже актуален')
|
||||||
|
return
|
||||||
|
raise # Другие ошибки пробрасываем
|
||||||
else:
|
else:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text=text,
|
text=text,
|
||||||
@@ -233,6 +238,6 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
|
error_text = "❌ <b>Ошибка загрузки списка</b>\n\nПопробуйте позже"
|
||||||
|
|
||||||
if is_callback:
|
if is_callback:
|
||||||
await update.answer("❌ Ошибка загрузки", show_alert=True)
|
await update.answer(f"❌ Ошибка загрузки: {e}", show_alert=True)
|
||||||
else:
|
else:
|
||||||
await message.answer(error_text, parse_mode="HTML")
|
await message.answer(error_text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
Обработчики команды /report для пользователей
|
Обработчики команды /report для пользователей
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message, CallbackQuery, User
|
from aiogram.types import Message, CallbackQuery, User
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
|
||||||
|
|
||||||
from bot.filters.admin import IsAdmin
|
from bot.filters.admin import IsAdmin
|
||||||
from configs import settings, COMMANDS
|
from configs import settings, COMMANDS
|
||||||
@@ -18,29 +19,13 @@ __all__ = ("router",)
|
|||||||
router: Router = Router(name="report_router")
|
router: Router = Router(name="report_router")
|
||||||
|
|
||||||
|
|
||||||
# ================= НАСТРОЙКИ =================
|
|
||||||
|
|
||||||
# ID чата для отправки репортов (можно вынести в configs)
|
|
||||||
# Если None, репорты отправляются всем владельцам в ЛС
|
|
||||||
REPORT_CHAT_ID = getattr(settings, 'REPORT_CHAT_ID', None)
|
|
||||||
|
|
||||||
|
|
||||||
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
# ================= ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ =================
|
||||||
|
|
||||||
def format_user(user: User) -> str:
|
def format_user(user: User) -> str:
|
||||||
"""
|
"""Форматирует информацию о пользователе."""
|
||||||
Форматирует информацию о пользователе.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: Объект User
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Отформатированная строка с именем и username
|
|
||||||
"""
|
|
||||||
if not user:
|
if not user:
|
||||||
return "Unknown User"
|
return "Unknown User"
|
||||||
|
|
||||||
# Формируем имя
|
|
||||||
name_parts = []
|
name_parts = []
|
||||||
if user.first_name:
|
if user.first_name:
|
||||||
name_parts.append(user.first_name)
|
name_parts.append(user.first_name)
|
||||||
@@ -49,10 +34,8 @@ def format_user(user: User) -> str:
|
|||||||
|
|
||||||
full_name = " ".join(name_parts) if name_parts else "No Name"
|
full_name = " ".join(name_parts) if name_parts else "No Name"
|
||||||
|
|
||||||
# Добавляем username если есть
|
|
||||||
if user.username:
|
if user.username:
|
||||||
return f"{full_name} (@{user.username})"
|
return f"{full_name} (@{user.username})"
|
||||||
else:
|
|
||||||
return full_name
|
return full_name
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +55,8 @@ def get_report_keyboard(
|
|||||||
chat_id: int,
|
chat_id: int,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
reported_user_id: int,
|
reported_user_id: int,
|
||||||
report_id: str
|
report_id: str,
|
||||||
|
message_thread_id: int | None = None
|
||||||
) -> InlineKeyboardBuilder:
|
) -> InlineKeyboardBuilder:
|
||||||
"""
|
"""
|
||||||
Создает клавиатуру для репорта.
|
Создает клавиатуру для репорта.
|
||||||
@@ -82,17 +66,19 @@ def get_report_keyboard(
|
|||||||
message_id: ID сообщения
|
message_id: ID сообщения
|
||||||
reported_user_id: ID пользователя, на которого пожаловались
|
reported_user_id: ID пользователя, на которого пожаловались
|
||||||
report_id: Уникальный ID репорта
|
report_id: Уникальный ID репорта
|
||||||
|
message_thread_id: ID топика (если есть)
|
||||||
"""
|
"""
|
||||||
ikb = InlineKeyboardBuilder()
|
ikb = InlineKeyboardBuilder()
|
||||||
|
|
||||||
# Кнопки действий
|
thread_id = message_thread_id if message_thread_id is not None else 0
|
||||||
|
|
||||||
ikb.button(
|
ikb.button(
|
||||||
text="🚫 Забанить",
|
text="🚫 Забанить",
|
||||||
callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}"
|
callback_data=f"report:ban:{chat_id}:{reported_user_id}:{report_id}:{thread_id}"
|
||||||
)
|
)
|
||||||
ikb.button(
|
ikb.button(
|
||||||
text="🗑 Удалить",
|
text="🗑 Удалить",
|
||||||
callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}"
|
callback_data=f"report:delete:{chat_id}:{message_id}:{report_id}:{thread_id}"
|
||||||
)
|
)
|
||||||
ikb.button(
|
ikb.button(
|
||||||
text="✅ Закрыть",
|
text="✅ Закрыть",
|
||||||
@@ -115,17 +101,10 @@ async def report_cmd(message: Message) -> None:
|
|||||||
"""
|
"""
|
||||||
Отправляет жалобу на сообщение администраторам.
|
Отправляет жалобу на сообщение администраторам.
|
||||||
|
|
||||||
Доступно всем пользователям.
|
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
/report — в ответ на сообщение
|
/report — в ответ на сообщение
|
||||||
/report <причина> — в ответ на сообщение с указанием причины
|
/report <причина> — в ответ на сообщение с указанием причины
|
||||||
|
|
||||||
Пример:
|
|
||||||
/report спам
|
|
||||||
/report оскорбления
|
|
||||||
"""
|
"""
|
||||||
# Проверяем, что команда в ответ на сообщение
|
|
||||||
if not message.reply_to_message:
|
if not message.reply_to_message:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
"❌ <b>Используйте команду в ответ на сообщение</b>\n\n"
|
||||||
@@ -133,7 +112,7 @@ async def report_cmd(message: Message) -> None:
|
|||||||
"1. Ответьте на сообщение нарушителя\n"
|
"1. Ответьте на сообщение нарушителя\n"
|
||||||
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
|
"2. Напишите <code>/report</code> или <code>/report причина</code>\n\n"
|
||||||
"Пример: <code>/report спам</code>",
|
"Пример: <code>/report спам</code>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -141,103 +120,104 @@ async def report_cmd(message: Message) -> None:
|
|||||||
reported_user = reported_message.from_user
|
reported_user = reported_message.from_user
|
||||||
reporter = message.from_user
|
reporter = message.from_user
|
||||||
|
|
||||||
# Проверка на None
|
|
||||||
if not reported_user or not reporter:
|
if not reported_user or not reporter:
|
||||||
await message.answer("❌ <b>Ошибка получения данных пользователя</b>", parse_mode="HTML")
|
await message.answer(
|
||||||
|
"❌ <b>Ошибка получения данных пользователя</b>",
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Нельзя пожаловаться на самого себя
|
|
||||||
if reported_user.id == reporter.id:
|
if reported_user.id == reporter.id:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Нельзя пожаловаться на самого себя</b>",
|
"⚠️ <b>Нельзя пожаловаться на самого себя</b>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Нельзя пожаловаться на бота
|
|
||||||
if reported_user.is_bot:
|
if reported_user.is_bot:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Нельзя пожаловаться на бота</b>",
|
"⚠️ <b>Нельзя пожаловаться на бота</b>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Нельзя пожаловаться на администратора
|
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
|
is_admin = await manager.is_admin(reported_user.id) or reported_user.id in settings.OWNER_ID
|
||||||
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"⚠️ <b>Нельзя пожаловаться на администратора</b>",
|
"⚠️ <b>Нельзя пожаловаться на администратора</b>",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Извлекаем причину (опционально)
|
parts = (message.text or "").split(maxsplit=1)
|
||||||
parts = message.text.split(maxsplit=1)
|
|
||||||
reason = parts[1] if len(parts) > 1 else "Не указана"
|
reason = parts[1] if len(parts) > 1 else "Не указана"
|
||||||
|
|
||||||
# Генерируем ID репорта
|
|
||||||
report_id = generate_report_id()
|
report_id = generate_report_id()
|
||||||
|
|
||||||
# === ФОРМИРУЕМ СООБЩЕНИЕ РЕПОРТА ===
|
# thread/topic исходного сообщения (если репортят из топика)
|
||||||
|
original_message_thread_id = reported_message.message_thread_id
|
||||||
|
|
||||||
report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
|
report_text = "🚨 <b>НОВЫЙ РЕПОРТ</b>\n\n"
|
||||||
|
|
||||||
# Информация о жалобщике
|
|
||||||
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
|
report_text += f"👤 <b>От:</b> {format_user(reporter)} (<code>{reporter.id}</code>)\n"
|
||||||
|
|
||||||
# Информация о нарушителе
|
|
||||||
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
|
report_text += f"⚠️ <b>На:</b> {format_user(reported_user)} (<code>{reported_user.id}</code>)\n\n"
|
||||||
|
|
||||||
# Информация о чате
|
|
||||||
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
|
chat_title = message.chat.title if message.chat.title else "Личные сообщения"
|
||||||
report_text += f"💬 <b>Чат:</b> {chat_title}\n"
|
report_text += f"💬 <b>Чат:</b> {chat_title}\n"
|
||||||
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n\n"
|
report_text += f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
|
||||||
|
|
||||||
|
if original_message_thread_id:
|
||||||
|
report_text += f"📌 <b>Topic ID:</b> <code>{original_message_thread_id}</code>\n"
|
||||||
|
report_text += "\n"
|
||||||
|
|
||||||
# Причина
|
|
||||||
report_text += f"📝 <b>Причина:</b> {reason}\n\n"
|
report_text += f"📝 <b>Причина:</b> {reason}\n\n"
|
||||||
|
report_text += "📄 <b>Текст сообщения:</b>\n"
|
||||||
|
|
||||||
# Текст сообщения
|
message_content = None
|
||||||
report_text += f"📄 <b>Текст сообщения:</b>\n"
|
|
||||||
|
|
||||||
if reported_message.text:
|
if reported_message.text:
|
||||||
truncated_text = truncate_text(reported_message.text, max_length=300)
|
truncated_text = truncate_text(reported_message.text, max_length=300)
|
||||||
report_text += f"<code>{truncated_text}</code>\n\n"
|
report_text += f"<code>{truncated_text}</code>\n\n"
|
||||||
|
message_content = reported_message.text
|
||||||
elif reported_message.caption:
|
elif reported_message.caption:
|
||||||
truncated_caption = truncate_text(reported_message.caption, max_length=300)
|
truncated_caption = truncate_text(reported_message.caption, max_length=300)
|
||||||
report_text += f"<code>{truncated_caption}</code>\n\n"
|
report_text += f"<code>{truncated_caption}</code>\n\n"
|
||||||
|
message_content = reported_message.caption
|
||||||
else:
|
else:
|
||||||
content_type = reported_message.content_type
|
report_text += f"<i>[{reported_message.content_type}]</i>\n\n"
|
||||||
report_text += f"<i>[{content_type}]</i>\n\n"
|
|
||||||
|
|
||||||
# Время
|
|
||||||
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
|
report_text += f"🕐 <b>Время:</b> {format_datetime(datetime.now())}\n"
|
||||||
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
|
report_text += f"🔗 <b>Message ID:</b> <code>{reported_message.message_id}</code>\n\n"
|
||||||
|
|
||||||
report_text += f"💡 <i>ID репорта: {report_id}</i>"
|
report_text += f"💡 <i>ID репорта: {report_id}</i>"
|
||||||
|
|
||||||
# Клавиатура
|
|
||||||
keyboard = get_report_keyboard(
|
keyboard = get_report_keyboard(
|
||||||
chat_id=message.chat.id,
|
chat_id=message.chat.id,
|
||||||
message_id=reported_message.message_id,
|
message_id=reported_message.message_id,
|
||||||
reported_user_id=reported_user.id,
|
reported_user_id=reported_user.id,
|
||||||
report_id=report_id
|
report_id=report_id,
|
||||||
|
message_thread_id=original_message_thread_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# === ОТПРАВКА РЕПОРТА ===
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Если указан админ-чат, отправляем туда
|
report_chat_id = settings.REPORT_CHAT_ID
|
||||||
if REPORT_CHAT_ID:
|
report_thread_id = settings.REPORT_THREAD_ID
|
||||||
await message.bot.send_message(
|
|
||||||
chat_id=REPORT_CHAT_ID,
|
# Нормализуем: 0 считаем как "без топика"
|
||||||
text=report_text,
|
if report_thread_id == 0:
|
||||||
parse_mode="HTML",
|
report_thread_id = None
|
||||||
reply_markup=keyboard.as_markup()
|
|
||||||
)
|
if report_chat_id:
|
||||||
|
send_params = {
|
||||||
|
"chat_id": report_chat_id,
|
||||||
|
"text": report_text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"reply_markup": keyboard.as_markup(),
|
||||||
|
}
|
||||||
|
if report_thread_id is not None:
|
||||||
|
send_params["message_thread_id"] = report_thread_id # отправка в конкретный топик
|
||||||
|
|
||||||
|
await message.bot.send_message(**send_params)
|
||||||
else:
|
else:
|
||||||
# Отправляем всем владельцам
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
for owner_id in settings.OWNER_ID:
|
for owner_id in settings.OWNER_ID:
|
||||||
try:
|
try:
|
||||||
@@ -254,24 +234,38 @@ async def report_cmd(message: Message) -> None:
|
|||||||
if sent_count == 0:
|
if sent_count == 0:
|
||||||
raise Exception("Не удалось отправить репорт ни одному владельцу")
|
raise Exception("Не удалось отправить репорт ни одному владельцу")
|
||||||
|
|
||||||
# Подтверждение пользователю
|
await manager.log_report(
|
||||||
|
report_id=report_id,
|
||||||
|
reporter_id=reporter.id,
|
||||||
|
reporter_username=reporter.username,
|
||||||
|
reported_user_id=reported_user.id,
|
||||||
|
reported_username=reported_user.username,
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
chat_title=chat_title,
|
||||||
|
message_id=reported_message.message_id,
|
||||||
|
message_thread_id=original_message_thread_id,
|
||||||
|
message_text=message_content,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"✅ <b>Жалоба отправлена администраторам</b>\n\n"
|
"✅ <b>Жалоба отправлена администраторам</b>\n\n"
|
||||||
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
|
"Спасибо за бдительность! Администраторы рассмотрят вашу жалобу.",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Логирование
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Репорт #{report_id}: {reporter.id} → {reported_user.id} в чате {message.chat.id}",
|
f"Репорт #{report_id}: {reporter.id} → {reported_user.id} в чате {message.chat.id}"
|
||||||
|
+ (f" (топик {original_message_thread_id})" if original_message_thread_id else ""),
|
||||||
log_type="REPORT"
|
log_type="REPORT"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
|
logger.error(f"Ошибка отправки репорта: {e}", log_type="REPORT")
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ <b>Ошибка отправки жалобы</b>\n\nПопробуйте позже или обратитесь к администратору напрямую.",
|
"❌ <b>Ошибка отправки жалобы</b>\n\n"
|
||||||
parse_mode="HTML"
|
"Попробуйте позже или обратитесь к администратору напрямую.",
|
||||||
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -280,30 +274,28 @@ async def report_cmd(message: Message) -> None:
|
|||||||
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
|
@router.callback_query(F.data.startswith("report:ban:"), IsAdmin())
|
||||||
async def report_ban_callback(callback: CallbackQuery) -> None:
|
async def report_ban_callback(callback: CallbackQuery) -> None:
|
||||||
"""Обрабатывает нажатие кнопки 'Забанить'"""
|
"""Обрабатывает нажатие кнопки 'Забанить'"""
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Парсим данные: report:ban:chat_id:user_id:report_id
|
parts = (callback.data or "").split(":")
|
||||||
parts = callback.data.split(":")
|
|
||||||
chat_id = int(parts[2])
|
chat_id = int(parts[2])
|
||||||
user_id = int(parts[3])
|
user_id = int(parts[3])
|
||||||
report_id = parts[4]
|
report_id = parts[4]
|
||||||
|
|
||||||
# Баним пользователя
|
|
||||||
try:
|
try:
|
||||||
await callback.bot.ban_chat_member(
|
await callback.bot.ban_chat_member(chat_id=chat_id, user_id=user_id)
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id
|
await manager.repo.update_report_status(
|
||||||
|
report_id=report_id,
|
||||||
|
status="banned",
|
||||||
|
processed_by=callback.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_name = format_user(callback.from_user)
|
admin_name = format_user(callback.from_user)
|
||||||
|
updated_text = (callback.message.text if callback.message else "") + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})"
|
||||||
|
|
||||||
# Обновляем сообщение
|
if callback.message:
|
||||||
updated_text = callback.message.text + f"\n\n✅ <b>Пользователь забанен</b> ({admin_name})"
|
await callback.message.edit_text(text=updated_text, parse_mode="HTML")
|
||||||
|
|
||||||
# Убираем кнопки
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text=updated_text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer("✅ Пользователь забанен", show_alert=True)
|
await callback.answer("✅ Пользователь забанен", show_alert=True)
|
||||||
|
|
||||||
@@ -323,30 +315,28 @@ async def report_ban_callback(callback: CallbackQuery) -> None:
|
|||||||
@router.callback_query(F.data.startswith("report:delete:"), IsAdmin())
|
@router.callback_query(F.data.startswith("report:delete:"), IsAdmin())
|
||||||
async def report_delete_callback(callback: CallbackQuery) -> None:
|
async def report_delete_callback(callback: CallbackQuery) -> None:
|
||||||
"""Обрабатывает нажатие кнопки 'Удалить'"""
|
"""Обрабатывает нажатие кнопки 'Удалить'"""
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Парсим данные: report:delete:chat_id:message_id:report_id
|
parts = (callback.data or "").split(":")
|
||||||
parts = callback.data.split(":")
|
|
||||||
chat_id = int(parts[2])
|
chat_id = int(parts[2])
|
||||||
message_id = int(parts[3])
|
message_id = int(parts[3])
|
||||||
report_id = parts[4]
|
report_id = parts[4]
|
||||||
|
|
||||||
# Удаляем сообщение
|
|
||||||
try:
|
try:
|
||||||
await callback.bot.delete_message(
|
await callback.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message_id
|
await manager.repo.update_report_status(
|
||||||
|
report_id=report_id,
|
||||||
|
status="deleted",
|
||||||
|
processed_by=callback.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_name = format_user(callback.from_user)
|
admin_name = format_user(callback.from_user)
|
||||||
|
updated_text = (callback.message.text if callback.message else "") + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})"
|
||||||
|
|
||||||
# Обновляем сообщение
|
if callback.message:
|
||||||
updated_text = callback.message.text + f"\n\n🗑 <b>Сообщение удалено</b> ({admin_name})"
|
await callback.message.edit_text(text=updated_text, parse_mode="HTML")
|
||||||
|
|
||||||
# Убираем кнопки
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text=updated_text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
|
|
||||||
await callback.answer("✅ Сообщение удалено", show_alert=True)
|
await callback.answer("✅ Сообщение удалено", show_alert=True)
|
||||||
|
|
||||||
@@ -365,25 +355,28 @@ async def report_delete_callback(callback: CallbackQuery) -> None:
|
|||||||
|
|
||||||
@router.callback_query(F.data.startswith("report:close:"), IsAdmin())
|
@router.callback_query(F.data.startswith("report:close:"), IsAdmin())
|
||||||
async def report_close_callback(callback: CallbackQuery) -> None:
|
async def report_close_callback(callback: CallbackQuery) -> None:
|
||||||
"""Обрабатывает нажатие кнопки 'Закрыть'"""
|
"""Обрабатывает нажатие кнопки 'Закрыть' (и удаляет сообщение репорта)"""
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Парсим данные: report:close:report_id
|
parts = (callback.data or "").split(":")
|
||||||
parts = callback.data.split(":")
|
|
||||||
report_id = parts[2]
|
report_id = parts[2]
|
||||||
|
|
||||||
admin_name = format_user(callback.from_user)
|
await manager.repo.update_report_status(
|
||||||
|
report_id=report_id,
|
||||||
# Обновляем сообщение
|
status="closed",
|
||||||
updated_text = callback.message.text + f"\n\n✅ <b>Репорт закрыт</b> ({admin_name})"
|
processed_by=callback.from_user.id
|
||||||
|
|
||||||
# Убираем кнопки
|
|
||||||
await callback.message.edit_text(
|
|
||||||
text=updated_text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.answer("✅ Репорт закрыт")
|
await callback.answer("✅ Репорт закрыт")
|
||||||
|
|
||||||
|
# Удаляем сообщение с репортом в админ-чате/топике
|
||||||
|
if callback.message:
|
||||||
|
try:
|
||||||
|
await callback.message.delete()
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.warning(f"Не удалось удалить сообщение репорта: {e}", log_type="REPORT")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
|
f"Репорт #{report_id} закрыт админом {callback.from_user.id}",
|
||||||
log_type="REPORT"
|
log_type="REPORT"
|
||||||
@@ -394,15 +387,11 @@ async def report_close_callback(callback: CallbackQuery) -> None:
|
|||||||
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
await callback.answer("❌ Ошибка выполнения", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# ================= ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ =================
|
# ================= ДОПОЛНИТЕЛЬНЫЕ КОМАНДЫ =================
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
|
@router.message(Command(*COMMANDS.get("reporthelp", ["reporthelp"]), prefix=settings.PREFIX, ignore_case=True))
|
||||||
async def report_help_cmd(message: Message) -> None:
|
async def report_help_cmd(message: Message) -> None:
|
||||||
"""
|
"""Показывает справку по системе репортов."""
|
||||||
Показывает справку по системе репортов.
|
|
||||||
|
|
||||||
Доступно всем пользователям.
|
|
||||||
"""
|
|
||||||
text = (
|
text = (
|
||||||
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n"
|
"🚨 <b>СИСТЕМА РЕПОРТОВ</b>\n\n"
|
||||||
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
|
"Используйте команду /report, чтобы пожаловаться на сообщение администраторам.\n\n"
|
||||||
@@ -425,23 +414,44 @@ async def report_help_cmd(message: Message) -> None:
|
|||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
@router.message(
|
||||||
async def report_stats_cmd(message: Message) -> None:
|
Command(*COMMANDS.get("reportstats", ["reportstats"]), prefix=settings.PREFIX, ignore_case=True),
|
||||||
"""
|
IsAdmin()
|
||||||
Показывает статистику по репортам (для админов).
|
|
||||||
|
|
||||||
TODO: Реализовать сохранение статистики в БД
|
|
||||||
"""
|
|
||||||
text = (
|
|
||||||
"📊 <b>СТАТИСТИКА РЕПОРТОВ</b>\n\n"
|
|
||||||
"⚠️ <i>Функция в разработке</i>\n\n"
|
|
||||||
"Планируется:\n"
|
|
||||||
"• Всего репортов за всё время\n"
|
|
||||||
"• Топ жалобщиков\n"
|
|
||||||
"• Топ нарушителей\n"
|
|
||||||
"• Распределение по причинам\n"
|
|
||||||
"• Статистика обработки\n\n"
|
|
||||||
"💡 <i>Для реализации нужно добавить таблицу reports в БД</i>"
|
|
||||||
)
|
)
|
||||||
|
async def report_stats_cmd(message: Message) -> None:
|
||||||
|
"""Показывает статистику по репортам (для админов)"""
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
|
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")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from bot.filters.admin import IsAdmin
|
from bot.filters.admin import IsAdmin
|
||||||
from configs import settings, COMMANDS
|
from configs import settings, COMMANDS
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
from bot.utils.decorators import log_action
|
from bot.utils import log_action, tg_emoji
|
||||||
|
|
||||||
__all__ = ("router",)
|
__all__ = ("router",)
|
||||||
|
|
||||||
CMD: str = "start"
|
CMD: str = "start"
|
||||||
|
|
||||||
router: Router = Router(name="start_cmd_router")
|
router: Router = Router(name="start_cmd_router")
|
||||||
|
|
||||||
def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"):
|
def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"):
|
||||||
@@ -21,7 +23,6 @@ def kb(text: str = "Создатель⬆️", url: str = "https://t.me/verdise"
|
|||||||
ikb.button(text=text, url=url)
|
ikb.button(text=text, url=url)
|
||||||
return ikb.as_markup()
|
return ikb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.casefold() == CMD)
|
@router.callback_query(F.data.casefold() == CMD)
|
||||||
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
||||||
@log_action(action_name="START_COMMAND", log_args=True)
|
@log_action(action_name="START_COMMAND", log_args=True)
|
||||||
@@ -36,6 +37,7 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
update: Message или CallbackQuery
|
update: Message или CallbackQuery
|
||||||
"""
|
"""
|
||||||
print(123)
|
print(123)
|
||||||
|
|
||||||
# Определяем тип update и извлекаем данные
|
# Определяем тип update и извлекаем данные
|
||||||
if isinstance(update, CallbackQuery):
|
if isinstance(update, CallbackQuery):
|
||||||
message = update.message
|
message = update.message
|
||||||
@@ -51,98 +53,89 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# Формируем текст помощи
|
# Формируем текст помощи
|
||||||
help_text = (
|
help_text = (
|
||||||
"🤖 <b>PrimoGuard - Бот-модератор</b>\n\n"
|
f'{tg_emoji(4961073056677103064)} <b>PrimoGuard - Бот-модератор</b>\n\n'
|
||||||
"Автоматическое удаление сообщений с запрещёнными словами.\n"
|
'<blockquote>Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.</blockquote>\n\n'
|
||||||
"Поддержка подстрок, лемм, временных блокировок и режимов модерации.\n\n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Команды просмотра ===
|
# === Команды просмотра ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"📋 <b>Просмотр:</b>\n"
|
f'{tg_emoji(4961141003059725568)} <b>Просмотр:</b>\n'
|
||||||
"/list — список всех правил и слов\n"
|
'<b>/list</b> — список всех правил и слов\n'
|
||||||
"/stats — статистика по удалениям\n"
|
'<b>/stats</b> — статистика по удалениям\n'
|
||||||
"/id — получение айди пользователя\n"
|
'<b>/id</b> — получение айди пользователя\n'
|
||||||
"/chatid — получение айди чата\n\n"
|
'<b>/chatid</b> — получение айди чата\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Постоянные банворды ===
|
# === Постоянные банворды ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"➕ <b>Добавить банворд (постоянно):</b>\n"
|
f'{tg_emoji(4961019408240608234)} <b>Добавить банворд (постоянно):</b>\n'
|
||||||
"/addword <code>слово</code> — подстрока (простой поиск)\n"
|
'<code>/addword</code> <code>слово</code> — подстрока (простой поиск)\n'
|
||||||
"/addlemma <code>слово</code> — лемма (все формы слова)\n"
|
'<code>/addlemma</code> <code>слово</code> — лемма (все формы слова)\n'
|
||||||
"/addpart <code>комбинация</code> — часть (поиск без пробелов)\n\n"
|
'<code>/addpart</code> <code>комбинация</code> — часть (поиск без пробелов)\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Временные банворды ===
|
# === Временные банворды ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"⏱ <b>Добавить банворд (временно):</b>\n"
|
f'{tg_emoji(4960719190026618714)} <b>Добавить банворд (временно):</b>\n'
|
||||||
"/addtempword <code>слово минуты</code> — временная подстрока\n"
|
'<code>/addtempword</code> <code>слово минуты</code> — временная подстрока\n'
|
||||||
"/addtemplemma <code>слово минуты</code> — временная лемма\n"
|
'<code>/addtemplemma</code> <code>слово минуты</code> — временная лемма\n'
|
||||||
"<i>Пример: /addtempword спам 60</i>\n\n"
|
'<i>Пример: /addtempword спам 60</i>\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Исключения (whitelist) ===
|
# === Исключения (whitelist) ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"✅ <b>Исключения (whitelist):</b>\n"
|
f'{tg_emoji(4963010134172239128)} <b>Исключения (whitelist):</b>\n'
|
||||||
"/addexcept <code>текст</code> — добавить исключение\n"
|
'<code>/addexcept</code> <code>текст</code> — добавить исключение\n'
|
||||||
"/remexcept <code>текст</code> — удалить исключение\n"
|
'<code>/remexcept</code> <code>текст</code> — удалить исключение\n'
|
||||||
"<i>Исключения не проверяются фильтром</i>\n\n"
|
'<i>Исключения не проверяются фильтром</i>\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Режимы модерации ===
|
# === Режимы модерации ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"🔇 <b>Режим тишины:</b>\n"
|
f'{tg_emoji(4960987543878239236)} <b>Режим тишины:</b>\n'
|
||||||
"/silence <code>минуты</code> — удалять ВСЕ сообщения\n"
|
'<code>/silence</code> <code>минуты</code> — удалять ВСЕ сообщения\n'
|
||||||
"/unsilence — отключить режим тишины\n"
|
'<b>/unsilence</b> — отключить режим тишины\n'
|
||||||
"/report — отправить репорт\n\n"
|
'<code>/report</code> — отправить репорт\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
help_text += (
|
help_text += (
|
||||||
"⚔️ <b>Режим антиконфликта:</b>\n"
|
f'{tg_emoji(4960986152308835400)} <b>Режим антиконфликта:</b>\n'
|
||||||
"/addconflictword <code>слово</code> — добавить конфликтное слово\n"
|
'<code>/addconflictword</code> <code>слово</code> — добавить конфликтное слово\n'
|
||||||
"/addconflictlemma <code>слово</code> — добавить конфликтную лемму\n"
|
'<code>/addconflictlemma</code> <code>слово</code> — добавить конфликтную лемму\n'
|
||||||
"/stopconflict <code>минуты</code> — активировать режим\n"
|
'<code>/stopconflict</code> <code>минуты</code> — активировать режим\n'
|
||||||
"/unstopconflict — отключить режим\n\n"
|
'<code>/unstopconflict</code> — отключить режим\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Удаление ===
|
# === Удаление ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"➖ <b>Удалить:</b>\n"
|
f'{tg_emoji(4961196485447254983)} <b>Удалить:</b>\n'
|
||||||
"/remword <code>слово</code> — удалить подстроку\n"
|
'<code>/remword</code> <code>слово</code> — удалить подстроку\n'
|
||||||
"/remlemma <code>слово</code> — удалить лемму\n"
|
'<code>/remlemma</code> <code>слово</code> — удалить лемму\n'
|
||||||
"/rempart <code>комбинация</code> — удалить часть\n"
|
'<code>/rempart</code> <code>комбинация</code> — удалить часть\n'
|
||||||
"/remtempword <code>слово</code> — удалить временную подстроку\n"
|
'<code>/remtempword</code> <code>слово</code> — удалить временную подстроку\n'
|
||||||
"/remtemplemma <code>слово</code> — удалить временную лемму\n"
|
'<code>/remtemplemma</code> <code>слово</code> — удалить временную лемму\n'
|
||||||
"/remconflictword <code>слово</code> — удалить конфликтное слово\n"
|
'<code>/remconflictword</code> <code>слово</code> — удалить конфликтное слово\n'
|
||||||
"/remconflictlemma <code>слово</code> — удалить конфликтную лемму\n\n"
|
'<code>/remconflictlemma</code> <code>слово</code> — удалить конфликтную лемму\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Управление админами (только для суперадминов) ===
|
# === Управление админами (только для суперадминов) ===
|
||||||
if is_super_admin:
|
if is_super_admin:
|
||||||
help_text += (
|
help_text += (
|
||||||
"👑 <b>Управление админами (только для владельцев):</b>\n"
|
f'{tg_emoji(4960891456869893259)} <b>Управление админами (только для владельцев):</b>\n'
|
||||||
"/addadmin <code>ID</code> — добавить администратора\n"
|
'<code>/addadmin</code> <i>ID</i> — добавить администратора\n'
|
||||||
"/remadmin <code>ID</code> — удалить администратора\n"
|
'<code>/remadmin</code> <i>ID</i> — удалить администратора\n'
|
||||||
"/listadmins — список всех админов\n\n"
|
'<b>/redactcomment</b> — изменить комментарий под постом\n'
|
||||||
|
'<b>/listadmins</b> — список всех админов\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Типы проверок ===
|
# === Типы проверок ===
|
||||||
help_text += (
|
help_text += (
|
||||||
"ℹ️ <b>Типы проверок:</b>\n"
|
f'{tg_emoji(4961021096162755737)} <b>Типы проверок:</b>\n'
|
||||||
"• <b>Подстрока</b> — простой поиск в тексте\n"
|
'• <b>Подстрока</b> — простой поиск в тексте\n'
|
||||||
"• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n"
|
'• <b>Лемма</b> — все формы слова (купить→куплю, купил, купишь...)\n'
|
||||||
"• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n"
|
'• <b>Часть</b> — поиск без пробелов (обходит \"к у п и т ь\")\n'
|
||||||
"• <b>Временные</b> — автоматически удаляются через N минут\n"
|
'• <b>Временные</b> — автоматически удаляются через N минут\n'
|
||||||
"• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n"
|
'• <b>Конфликтные</b> — работают только в режиме /stopconflict\n\n'
|
||||||
)
|
|
||||||
|
|
||||||
help_text += (
|
|
||||||
"🔧 <b>Технологии:</b>\n"
|
|
||||||
"• Unicode-нормализация (латиница→кириллица)\n"
|
|
||||||
"• Обход через разделители (\"с п а м\" → \"спам\")\n"
|
|
||||||
"• Морфологический анализ (pymorphy3)\n"
|
|
||||||
"• SQLAlchemy + SQLite с кэшированием\n\n"
|
|
||||||
"💾 Все настройки сохраняются в базе данных"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отправляем ответ
|
# Отправляем ответ
|
||||||
@@ -166,4 +159,4 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
log_type="ERROR"
|
log_type="ERROR"
|
||||||
)
|
)
|
||||||
if is_callback:
|
if is_callback:
|
||||||
await update.answer("❌ Ошибка отображения справки", show_alert=True)
|
await update.answer(f'{tg_emoji(4963277744994518278)} Ошибка отображения справки', show_alert=True)
|
||||||
|
|||||||
@@ -1,11 +1,242 @@
|
|||||||
|
"""
|
||||||
|
Триггер-хэндлер: реагирует на обращения к Лайле с именем персонажа.
|
||||||
|
Формат: "Лайла [что угодно] [имя или псевдоним]"
|
||||||
|
"""
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import random
|
||||||
|
|
||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
|
|
||||||
# Настройки экспорта и роутера
|
__all__ = ("router",)
|
||||||
router: Router = Router(name=__name__)
|
router: Router = Router(name="triggers_router")
|
||||||
|
|
||||||
|
|
||||||
|
CHARACTERS: Dict[str, Dict] = {
|
||||||
|
"эвелин": {
|
||||||
|
"aliases": ["эвелин", "эва", "эви"],
|
||||||
|
"answers": [
|
||||||
|
"Эвелин умеет молчать так, что хочется говорить.",
|
||||||
|
"Эва всегда знает больше, чем говорит. Это немного пугает.",
|
||||||
|
"С ней рядом становится спокойно. Не знаю почему.",
|
||||||
|
"Интересно, о чём она думает в тишине...",
|
||||||
|
"Эвелин тихая снаружи. Но внутри — целый ураган, я уверена.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"лео": {
|
||||||
|
"aliases": ["лео", "лёва", "лёня"],
|
||||||
|
"answers": [
|
||||||
|
"Лео громкий, яркий и немного безрассудный. Мне нравится!",
|
||||||
|
"Он смеётся первым и уходит последним. Настоящий.",
|
||||||
|
"Лео всегда найдёт повод для праздника, даже если его нет.",
|
||||||
|
"Кажется, он боится тишины. Поэтому и заполняет её собой.",
|
||||||
|
"За его смехом прячется что-то очень серьёзное...",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"маркус": {
|
||||||
|
"aliases": ["маркус", "марк"],
|
||||||
|
"answers": [
|
||||||
|
"Маркус говорит мало, но каждое слово весит.",
|
||||||
|
"Он из тех, кто держит слово даже когда это неудобно.",
|
||||||
|
"С Маркусом не поспоришь. Не потому что нельзя — просто незачем.",
|
||||||
|
"Он смотрит так, будто видит тебя насквозь. Немного жутковато.",
|
||||||
|
"Маркус — тот, на кого можно положиться в самый плохой день.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"мари": {
|
||||||
|
"aliases": ["мари", "маришка", "мариша"],
|
||||||
|
"answers": [
|
||||||
|
"Мари — это как утренний свет. Мягко и неожиданно тепло.",
|
||||||
|
"Она помнит мелочи, которые другие не замечают. Это её суперсила.",
|
||||||
|
"С Мари любой разговор становится важным.",
|
||||||
|
"Она улыбается даже когда грустно. Не притворяется — просто верит.",
|
||||||
|
"Мари умеет прощать. Это редкость.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"либе": {
|
||||||
|
"aliases": ["либе", "либ"],
|
||||||
|
"answers": [
|
||||||
|
"Либе... имя звучит как песня на незнакомом языке.",
|
||||||
|
"Она всегда чуть в стороне, но именно к ней тянутся люди.",
|
||||||
|
"Либе видит красоту там, где другие видят хаос.",
|
||||||
|
"Она не объясняет себя. И не должна.",
|
||||||
|
"С Либе можно молчать — и это не будет неловко.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"мотциэль": {
|
||||||
|
"aliases": ["мотциэль", "мотц", "моц"],
|
||||||
|
"answers": [
|
||||||
|
"Мотциэль... даже имя звучит как заклинание.",
|
||||||
|
"Он существует между мирами. Буквально.",
|
||||||
|
"Спрашивать его о прошлом — плохая идея. Очень плохая.",
|
||||||
|
"Мотциэль помнит вещи, которых не было. Или были?",
|
||||||
|
"Его глаза смотрят в разные эпохи одновременно.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"виктор": {
|
||||||
|
"aliases": ["виктор", "вик", "витя"],
|
||||||
|
"answers": [
|
||||||
|
"Виктор всегда побеждает. Это в имени.",
|
||||||
|
"Он не злой. Просто у него другая шкала ценностей.",
|
||||||
|
"Виктор говорит правду даже когда это больно. Особенно когда больно.",
|
||||||
|
"Не стоит играть с ним в слова — проиграешь.",
|
||||||
|
"За его холодностью — старая-старая усталость.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"кситти": {
|
||||||
|
"aliases": ["кситти", "кси", "ксит"],
|
||||||
|
"answers": [
|
||||||
|
"Кситти — маленький хаос в красивой упаковке.",
|
||||||
|
"Она никогда не делает то, что от неё ожидают. Никогда.",
|
||||||
|
"С Кситти скучно не бывает. Опасно — бывает. Скучно — нет.",
|
||||||
|
"Она собирает странные вещи и странных людей.",
|
||||||
|
"Кситти смеётся над правилами. Потому что сама их придумывает.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"кадфаль": {
|
||||||
|
"aliases": ["кадфаль", "кад", "кадф"],
|
||||||
|
"answers": [
|
||||||
|
"Кадфаль несёт что-то древнее в каждом шаге.",
|
||||||
|
"Он не торопится. У него другое ощущение времени.",
|
||||||
|
"Кадфаль знает цену словам — поэтому тратит их редко.",
|
||||||
|
"В его присутствии хочется стоять прямо.",
|
||||||
|
"Он видел многое. Слишком многое для одной жизни.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"вайш": {
|
||||||
|
"aliases": ["вайш", "вай"],
|
||||||
|
"answers": [
|
||||||
|
"Вайш появляется неожиданно и исчезает так же.",
|
||||||
|
"Её след — это вопросы без ответов.",
|
||||||
|
"Вайш знает что-то, что тебе лучше не знать.",
|
||||||
|
"Она не объясняет своих решений. Просто делает.",
|
||||||
|
"С Вайш никогда не знаешь, друг она или нет.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"скаф": {
|
||||||
|
"aliases": ["скаф"],
|
||||||
|
"answers": [
|
||||||
|
"Скаф — имя, которое не забывается.",
|
||||||
|
"Он работает в тени. Не потому что боится света — просто так удобнее.",
|
||||||
|
"Скаф знает цену всему. Буквально всему.",
|
||||||
|
"Его нельзя купить. Его можно только нанять. Это разница.",
|
||||||
|
"Те, кто встречал Скафа, редко рассказывают об этом дважды.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"куарти": {
|
||||||
|
"aliases": ["куарти", "куар"],
|
||||||
|
"answers": [
|
||||||
|
"Куарти — четыре буквы и миллион вопросов.",
|
||||||
|
"Он улыбается, когда другие нервничают. Это не успокаивает.",
|
||||||
|
"Куарти коллекционирует долги. Чужие.",
|
||||||
|
"Говорят, он никогда не проигрывает. Говорят.",
|
||||||
|
"Куарти появляется именно тогда, когда тебе нужна помощь. И это не случайно.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"саэрин": {
|
||||||
|
"aliases": ["саэрин", "саэ", "сэрин"],
|
||||||
|
"answers": [
|
||||||
|
"Саэрин — как туман. Красиво и немного опасно.",
|
||||||
|
"Она говорит загадками не потому что хочет запутать — просто иначе не умеет.",
|
||||||
|
"Саэрин помнит всё, что ей говорят. Всё.",
|
||||||
|
"Её спокойствие пугает больше, чем чужой гнев.",
|
||||||
|
"Саэрин выбирает слова как оружие — точно и без лишнего.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"котики": {
|
||||||
|
"aliases": ["котики", "котик", "кот", "кошка"],
|
||||||
|
"answers": [
|
||||||
|
"Котики — это лучшее, что есть в этом мире. Без обсуждений.",
|
||||||
|
"Котик сел на тебя — ты избран.",
|
||||||
|
"Кот смотрит на тебя и думает что-то важное. Наверное.",
|
||||||
|
"Котики всегда правы. Это научный факт.",
|
||||||
|
"Маленький тёплый комочек счастья. Что ещё нужно?",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"нотик": {
|
||||||
|
"aliases": ["нотик", "нота", "нотка"],
|
||||||
|
"answers": [
|
||||||
|
"Нотик! Звучит как маленькая музыкальная нота! 🎵",
|
||||||
|
"Нотик — тот, кто приносит мелодию туда, где её не хватает.",
|
||||||
|
"Маленький, но важный. Как все хорошие вещи.",
|
||||||
|
"Нотик — это и ласково, и загадочно одновременно.",
|
||||||
|
"Из таких маленьких нотиков складываются большие истории.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"илья": {
|
||||||
|
"aliases": ["илья", "илюха", "илюша"],
|
||||||
|
"answers": [
|
||||||
|
"Илья — имя с характером. Твёрдое и живое.",
|
||||||
|
"Илья всегда знает что делать. Или уверенно делает вид.",
|
||||||
|
"С Ильёй легко — он не усложняет лишнего.",
|
||||||
|
"Он из тех, кто сделает, а потом расскажет. Не наоборот.",
|
||||||
|
"Илья редко жалуется. Чаще просто решает.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ина": {
|
||||||
|
"aliases": ["ина", "инка", "инуля"],
|
||||||
|
"answers": [
|
||||||
|
"Ина — короткое имя, за которым много всего.",
|
||||||
|
"Она тихая, но запоминается.",
|
||||||
|
"Ина умеет слушать так, что хочется говорить.",
|
||||||
|
"В ней есть что-то очень своё, неповторимое.",
|
||||||
|
"Ина — как маленький огонёк. Незаметный, но греет.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"абсцисс": {
|
||||||
|
"aliases": ["абсцисс", "абс"],
|
||||||
|
"answers": [
|
||||||
|
"Абсцисс! Это математика или имя? Хи-хи!",
|
||||||
|
"Ось абсцисс — горизонталь жизни. Всё движется по ней.",
|
||||||
|
"Абсцисс звучит как заклинание из учебника.",
|
||||||
|
"Кто-то мечтает о приключениях, а кто-то — об осях координат!",
|
||||||
|
"Абсцисс и ордината. Звучит как имена двух загадочных персонажей!",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имена, на которые Лайла откликается
|
||||||
|
LAYLA_NAMES = ["лайла", "лайл", "лая"]
|
||||||
|
|
||||||
|
|
||||||
|
def find_character_answer(text: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Проверяет:
|
||||||
|
1. Есть ли в тексте обращение к Лайле
|
||||||
|
2. Есть ли имя персонажа
|
||||||
|
|
||||||
|
Возвращает случайный ответ или None.
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Проверяем обращение к Лайле
|
||||||
|
if not any(name in text_lower for name in LAYLA_NAMES):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ищем персонажа
|
||||||
|
for character, data in CHARACTERS.items():
|
||||||
|
for alias in data["aliases"]:
|
||||||
|
if alias in text_lower:
|
||||||
|
return random.choice(data["answers"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.message()
|
@router.message()
|
||||||
async def default_msg(message: Message) -> None:
|
async def handle_triggers(message: Message) -> None:
|
||||||
"""Обработчик всех необработанных сообщений."""
|
"""
|
||||||
|
Реагирует только если:
|
||||||
|
- Сообщение от живого человека
|
||||||
|
- В тексте есть обращение к Лайле
|
||||||
|
- В тексте есть имя персонажа
|
||||||
|
|
||||||
|
На всё остальное — молчит.
|
||||||
|
"""
|
||||||
|
#if not message.text or not message.from_user or message.from_user.is_bot:
|
||||||
|
#return
|
||||||
|
|
||||||
|
#response = find_character_answer(message.text)
|
||||||
|
|
||||||
|
#if response:
|
||||||
|
#await message.reply(response)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from .decision import *
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,25 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Middleware для проверки сообщений на запрещённые слова (банворды).
|
Middleware для проверки сообщений на запрещённые слова (банворды).
|
||||||
|
|
||||||
Pipeline проверки:
|
✅ ИСПРАВЛЕНО:
|
||||||
1. Пропускаем админов и служебные сообщения
|
- Полная нормализация текста с использованием UNICODE_MAP
|
||||||
2. Проверяем whitelist (исключения)
|
- Удаление повторов символов (леееейн → лейн)
|
||||||
3. Проверяем режим silence (удаляем всё)
|
- Игнорирование разделителей (л.е.й.н → лейн)
|
||||||
4. Проверяем режим conflict (конфликтные слова)
|
- Поддержка всех типов проверок (SUBSTRING, LEMMA, PART, CONFLICT)
|
||||||
5. Проверяем постоянные банворды (substring, lemma, part)
|
- Белый список и режимы тишины/конфликта
|
||||||
6. Проверяем временные банворды
|
- Нет уведомлений в режиме тишины
|
||||||
7. Если найдено - удаляем, логируем, уведомляем админов
|
|
||||||
|
|
||||||
НОВОЕ: Все проверки работают с нормализацией повторяющихся букв (3+ → 1).
|
|
||||||
"""
|
"""
|
||||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
|
||||||
|
from typing import Callable, Dict, Any, Awaitable, Optional, Set
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
from configs import settings
|
from configs import settings, UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
|
||||||
from database import get_manager, BanWordType
|
from database import get_manager, BanWordType
|
||||||
from bot.special import process_text, extract_words, get_lemma
|
from bot.special import process_text, extract_words, get_lemma
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
@@ -27,18 +26,102 @@ from middleware.loggers import logger
|
|||||||
__all__ = ("BanWordsMiddleware",)
|
__all__ = ("BanWordsMiddleware",)
|
||||||
|
|
||||||
|
|
||||||
|
class TextNormalizer:
|
||||||
|
"""
|
||||||
|
Класс для многоступенчатой нормализации текста.
|
||||||
|
Приводит различные юникод-символы к базовым буквам,
|
||||||
|
удаляет повторы, убирает разделители.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Объединяем все словари замен в один
|
||||||
|
FULL_MAP = {}
|
||||||
|
FULL_MAP.update(LATIN_TO_CYRILLIC)
|
||||||
|
FULL_MAP.update(CYRILLIC_NORMALIZE)
|
||||||
|
FULL_MAP.update(UNICODE_MAP)
|
||||||
|
|
||||||
|
# Символы-разделители, которые могут быть вставлены между буквами
|
||||||
|
SEPARATORS = re.compile(r'[\s\.\-_,;:|]+', re.UNICODE)
|
||||||
|
|
||||||
|
# Паттерн для поиска повторяющихся букв (3+ раза)
|
||||||
|
REPEAT_PATTERN = re.compile(r'([а-яёa-z])\1{2,}', re.IGNORECASE)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_characters(cls, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Заменяет все символы из FULL_MAP на их базовые эквиваленты.
|
||||||
|
Проходит по строке посимвольно для максимальной замены.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for ch in text:
|
||||||
|
# Сначала пробуем заменить по карте
|
||||||
|
if ch in cls.FULL_MAP:
|
||||||
|
result.append(cls.FULL_MAP[ch])
|
||||||
|
else:
|
||||||
|
result.append(ch)
|
||||||
|
# Приводим к нижнему регистру после замен (чтобы избежать потери регистра в карте)
|
||||||
|
return ''.join(result).lower()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_separators(cls, text: str) -> str:
|
||||||
|
"""Удаляет разделители между буквами (пробелы, точки и т.д.)"""
|
||||||
|
return cls.SEPARATORS.sub('', text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def collapse_repeats(cls, text: str, max_repeat: int = 2) -> str:
|
||||||
|
"""
|
||||||
|
Заменяет повторения символов более max_repeat подряд на один/два символа.
|
||||||
|
По умолчанию оставляет максимум 2 (леееейн → леейн? но обычно хватит 2).
|
||||||
|
Можно настроить: для банворда "лейн" превратит "леееейн" в "леейн", что всё равно содержит "лейн".
|
||||||
|
"""
|
||||||
|
def repl(m):
|
||||||
|
ch = m.group(1)
|
||||||
|
# Оставляем два символа, чтобы не терять удвоенные буквы (например, "дд" в слове "поддон")
|
||||||
|
return ch * 2
|
||||||
|
return cls.REPEAT_PATTERN.sub(repl, text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_full(cls, text: str, remove_sep: bool = True, collapse: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Полная нормализация:
|
||||||
|
1. Unicode нормализация (NFKC) для разложения составных символов
|
||||||
|
2. Замена по карте
|
||||||
|
3. Приведение к нижнему регистру
|
||||||
|
4. Удаление разделителей (опционально)
|
||||||
|
5. Схлопывание повторов (опционально)
|
||||||
|
"""
|
||||||
|
# NFKC разлагает символы типа "ё" в "е" + умляут, но нам лучше оставить как есть,
|
||||||
|
# т.к. у нас есть прямые замены. Однако для совместимости применим.
|
||||||
|
text = unicodedata.normalize('NFKC', text)
|
||||||
|
# Замена символов
|
||||||
|
text = cls.normalize_characters(text)
|
||||||
|
# Удаление разделителей
|
||||||
|
if remove_sep:
|
||||||
|
text = cls.remove_separators(text)
|
||||||
|
# Схлопывание повторов
|
||||||
|
if collapse:
|
||||||
|
text = cls.collapse_repeats(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_for_part(cls, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Нормализация для типа PART:
|
||||||
|
- Полная нормализация
|
||||||
|
- Удаление всех не-буквенных символов (кроме пробелов)
|
||||||
|
- Приведение к нижнему регистру
|
||||||
|
"""
|
||||||
|
text = cls.normalize_full(text, remove_sep=False, collapse=True)
|
||||||
|
# Оставляем только буквы и пробелы
|
||||||
|
text = re.sub(r'[^а-яёa-z\s]', '', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
return text.lower()
|
||||||
|
|
||||||
|
|
||||||
class BanWordsMiddleware(BaseMiddleware):
|
class BanWordsMiddleware(BaseMiddleware):
|
||||||
"""
|
|
||||||
Middleware для фильтрации сообщений с банвордами.
|
|
||||||
|
|
||||||
Проверяет каждое текстовое сообщение на наличие запрещённых слов,
|
|
||||||
удаляет спам и уведомляет администраторов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Инициализирует middleware"""
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = get_manager()
|
self.manager = get_manager()
|
||||||
|
self.normalizer = TextNormalizer()
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -46,245 +129,143 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
event: Message,
|
event: Message,
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
# Проверяем наличие текста или подписи
|
||||||
Обрабатывает входящие сообщения.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
handler: Следующий обработчик в цепочке
|
|
||||||
event: Сообщение от пользователя
|
|
||||||
data: Данные из диспетчера
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Any: Результат обработчика или None (если сообщение удалено)
|
|
||||||
"""
|
|
||||||
# Пропускаем не-текстовые сообщения
|
|
||||||
if not event.text and not event.caption:
|
if not event.text and not event.caption:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Получаем текст (из text или caption)
|
|
||||||
message_text = event.text or event.caption
|
message_text = event.text or event.caption
|
||||||
|
|
||||||
# Пропускаем команды (начинаются с /)
|
# Игнорируем команды
|
||||||
if message_text.startswith('/'):
|
if message_text.startswith('/'):
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Проверяем, является ли пользователь админом
|
# Проверка на админа
|
||||||
user_id = event.from_user.id
|
user_id = event.from_user.id
|
||||||
is_super_admin = user_id in settings.OWNER_ID
|
is_super_admin = user_id in settings.OWNER_ID
|
||||||
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
|
is_admin = is_super_admin or self.manager.is_admin_cached(user_id)
|
||||||
|
|
||||||
# Админы пропускаются
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Проверяем сообщение на банворды
|
# Проверяем сообщение на спам
|
||||||
spam_result = await self._check_message(message_text)
|
spam_result = await self._check_message(message_text)
|
||||||
|
|
||||||
if spam_result:
|
if spam_result:
|
||||||
# Найден спам - удаляем и уведомляем
|
|
||||||
await self._handle_spam(event, spam_result)
|
await self._handle_spam(event, spam_result)
|
||||||
return None # Не продолжаем обработку
|
return None # Сообщение удалено, дальше не обрабатываем
|
||||||
|
|
||||||
# Сообщение чистое - пропускаем дальше
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_for_part_check(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Нормализует текст для проверки частей слов.
|
|
||||||
Удаляет ВСЕ символы кроме букв и цифр, приводит к нижнему регистру.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Исходный текст
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Нормализованный текст (только буквы и цифры, нижний регистр)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
"@Astrixkeepbot" -> "astrixkeepbot"
|
|
||||||
"hello@world.com" -> "helloworldcom"
|
|
||||||
"test_123-456" -> "test123456"
|
|
||||||
"""
|
|
||||||
return re.sub(r'[^a-zA-Zа-яА-ЯёЁ0-9]', '', text.lower())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_repeated_chars(text: str, max_repeats: int = 1) -> str:
|
|
||||||
"""
|
|
||||||
Убирает повторяющиеся буквы (обход "лееейн" -> "лейн", "телееелооог" -> "телелог").
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Исходное слово
|
|
||||||
max_repeats: Максимальное количество повторов одной буквы (1 = убрать все повторы)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Нормализованное слово
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
("лееейн", 1) -> "лейн"
|
|
||||||
("телееелооог", 1) -> "телелог"
|
|
||||||
("хеееелооооу", 1) -> "хелоу"
|
|
||||||
("аааааа", 1) -> "а"
|
|
||||||
("привеееет", 2) -> "приввеет" (если max_repeats=2)
|
|
||||||
"""
|
|
||||||
if max_repeats == 1:
|
|
||||||
# Заменяем 2+ одинаковых букв подряд на 1 такую же букву
|
|
||||||
return re.sub(r'([а-яёa-z])\1+', r'\1', text, flags=re.IGNORECASE)
|
|
||||||
else:
|
|
||||||
# Заменяем (max_repeats+1)+ одинаковых букв на max_repeats таких букв
|
|
||||||
pattern = f'([а-яёa-z])\\1{{{max_repeats},}}'
|
|
||||||
replacement = '\\1' * max_repeats
|
|
||||||
return re.sub(pattern, replacement, text, flags=re.IGNORECASE)
|
|
||||||
|
|
||||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Проверяет сообщение на наличие банвордов.
|
Многоступенчатая проверка текста.
|
||||||
|
Возвращает словарь с причиной блокировки или None.
|
||||||
Args:
|
|
||||||
text: Текст сообщения
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[Dict]: {"word": "найденное_слово", "type": "тип_проверки"} или None
|
|
||||||
"""
|
"""
|
||||||
# Нормализуем текст для проверки
|
# 1. Повторяющиеся символы (например, "леееейн") — блокируем сразу
|
||||||
text_lower = text.lower()
|
repeat_result = self._check_repeated_chars(text)
|
||||||
text_processed = process_text(text_lower)
|
if repeat_result:
|
||||||
|
return repeat_result
|
||||||
|
|
||||||
# Дополнительно нормализуем повторяющиеся буквы для всех проверок
|
# 2. Получаем кэшированные списки
|
||||||
text_normalized = self._normalize_repeated_chars(text_processed, max_repeats=1)
|
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
||||||
|
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
||||||
|
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||||
|
conflict_substring = self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING)
|
||||||
|
conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
|
||||||
|
|
||||||
logger.debug(
|
# 3. Белый список
|
||||||
f"Проверка текста: исходный='{text[:50]}', обработанный='{text_processed[:50]}', "
|
if self.manager.is_whitelisted(text):
|
||||||
f"нормализованный='{text_normalized[:50]}'",
|
logger.debug(f"⏭️ Пропуск по белому списку: {text[:30]}", log_type="BANWORDS")
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
|
|
||||||
# === 1. WHITELIST (исключения) ===
|
|
||||||
# Проверяем оба варианта: с повторами и без
|
|
||||||
if self.manager.is_whitelisted(text_processed) or self.manager.is_whitelisted(text_normalized):
|
|
||||||
logger.debug(
|
|
||||||
f"Сообщение содержит whitelist слово",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# === 2. SILENCE MODE (удаляем всё) ===
|
# 4. Режим тишины
|
||||||
if await self.manager.is_silence_active():
|
if await self.manager.is_silence_active():
|
||||||
return {
|
return {"word": "[режим тишины]", "type": "silence"}
|
||||||
"word": "[режим тишины]",
|
|
||||||
"type": "silence"
|
|
||||||
}
|
|
||||||
|
|
||||||
# === 3. CONFLICT MODE (конфликтные слова) ===
|
# 5. Режим конфликта (более мягкие правила)
|
||||||
if await self.manager.is_conflict_active():
|
if await self.manager.is_conflict_active():
|
||||||
# Проверяем конфликтные подстроки (с нормализацией)
|
# Проверка conflict_substring (с нормализацией)
|
||||||
conflict_substring = self.manager.get_banwords_cached(
|
normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
|
||||||
BanWordType.CONFLICT_SUBSTRING
|
|
||||||
)
|
|
||||||
for word in conflict_substring:
|
for word in conflict_substring:
|
||||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
|
||||||
if word_normalized in text_normalized:
|
if norm_word in normalized_text:
|
||||||
return {"word": word, "type": "conflict_substring"}
|
return {"word": word, "type": "conflict_substring"}
|
||||||
|
|
||||||
# Проверяем конфликтные леммы
|
# conflict_lemma
|
||||||
conflict_lemma = self.manager.get_banwords_cached(
|
for word_text in extract_words(text):
|
||||||
BanWordType.CONFLICT_LEMMA
|
lemma = get_lemma(word_text)
|
||||||
)
|
|
||||||
words_in_text = extract_words(text_processed)
|
|
||||||
for word_text in words_in_text:
|
|
||||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
|
||||||
lemma = get_lemma(word_normalized)
|
|
||||||
|
|
||||||
if lemma in conflict_lemma:
|
if lemma in conflict_lemma:
|
||||||
return {"word": lemma, "type": "conflict_lemma"}
|
return {"word": lemma, "type": "conflict_lemma"}
|
||||||
|
|
||||||
# === 4. SUBSTRING (подстроки) с нормализацией ===
|
# Если в конфликтном режиме ничего не найдено — пропускаем
|
||||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
|
||||||
for word in substring_words:
|
|
||||||
# Нормализуем и банворд, и текст
|
|
||||||
word_normalized = self._normalize_repeated_chars(word, max_repeats=1)
|
|
||||||
|
|
||||||
if word_normalized in text_normalized:
|
|
||||||
logger.info(
|
|
||||||
f"Найдена подстрока: '{word}' (норм: '{word_normalized}') в '{text_normalized[:100]}'",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
return {"word": word, "type": "substring"}
|
|
||||||
|
|
||||||
# === 5. PART (части слов без пробелов и спецсимволов) ===
|
|
||||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
|
||||||
if part_words:
|
|
||||||
# Специальная нормализация для PART: удаляем ВСЁ кроме букв и цифр
|
|
||||||
text_part_normalized = self._normalize_for_part_check(text)
|
|
||||||
text_part_normalized = self._normalize_repeated_chars(text_part_normalized, max_repeats=1)
|
|
||||||
|
|
||||||
for part in part_words:
|
|
||||||
part_normalized = self._normalize_for_part_check(part)
|
|
||||||
part_normalized = self._normalize_repeated_chars(part_normalized, max_repeats=1)
|
|
||||||
|
|
||||||
if part_normalized in text_part_normalized:
|
|
||||||
logger.info(
|
|
||||||
f"Найдена запрещенная часть: '{part}' (норм: '{part_normalized}') "
|
|
||||||
f"в '{text_part_normalized[:100]}'",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
return {"word": part, "type": "part"}
|
|
||||||
|
|
||||||
# === 6. LEMMA (нормальные формы слов) ===
|
|
||||||
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
|
||||||
if lemma_words:
|
|
||||||
words_in_text = extract_words(text_processed)
|
|
||||||
for word_text in words_in_text:
|
|
||||||
# Убираем повторяющиеся буквы ПЕРЕД лемматизацией
|
|
||||||
word_normalized = self._normalize_repeated_chars(word_text, max_repeats=1)
|
|
||||||
lemma = get_lemma(word_normalized)
|
|
||||||
|
|
||||||
if lemma in lemma_words:
|
|
||||||
logger.info(
|
|
||||||
f"Найдена лемма: '{lemma}' из слова '{word_text}' (норм: '{word_normalized}')",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
return {"word": lemma, "type": "lemma"}
|
|
||||||
|
|
||||||
# Банворды не найдены
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _handle_spam(
|
# 6. Обычный режим: проверка substring (с удалением разделителей и схлопыванием повторов)
|
||||||
self,
|
normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
|
||||||
message: Message,
|
for word in substring_words:
|
||||||
spam_result: Dict[str, str]
|
norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
|
||||||
) -> None:
|
if norm_word in normalized_text:
|
||||||
"""
|
logger.info(f"✅ SUBSTRING: '{word}'", log_type="BANWORDS")
|
||||||
Обрабатывает найденный спам: удаляет, логирует, уведомляет.
|
return {"word": word, "type": "substring"}
|
||||||
|
|
||||||
Args:
|
# 7. Проверка part (строгая нормализация, только буквы и пробелы)
|
||||||
message: Сообщение со спамом
|
part_normalized = self.normalizer.normalize_for_part(text)
|
||||||
spam_result: Результат проверки (слово + тип)
|
for part in part_words:
|
||||||
|
norm_part = self.normalizer.normalize_for_part(part)
|
||||||
|
if norm_part in part_normalized:
|
||||||
|
logger.info(f"✅ PART: '{part}'", log_type="BANWORDS")
|
||||||
|
return {"word": part, "type": "part"}
|
||||||
|
|
||||||
|
# 8. Проверка lemma
|
||||||
|
for word_text in extract_words(text):
|
||||||
|
# Для леммы тоже применяем нормализацию (удаляем разделители, схлопываем повторы)
|
||||||
|
normalized_word = self.normalizer.normalize_full(word_text, remove_sep=True, collapse=True)
|
||||||
|
lemma = get_lemma(normalized_word)
|
||||||
|
if lemma in lemma_words:
|
||||||
|
logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS")
|
||||||
|
return {"word": lemma, "type": "lemma"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
|
Проверяет на наличие 3+ повторяющихся букв подряд.
|
||||||
|
Использует сырой текст без нормализации (чтобы поймать "леееейн").
|
||||||
|
"""
|
||||||
|
# Ищем повторения букв (только кириллица/латиница)
|
||||||
|
pattern = re.compile(r'([а-яёa-zA-Z])\1{2,}', re.IGNORECASE)
|
||||||
|
matches = pattern.finditer(text)
|
||||||
|
for match in matches:
|
||||||
|
char = match.group(1)
|
||||||
|
count = len(match.group(0))
|
||||||
|
if count >= 3:
|
||||||
|
logger.info(f"🔥 ПОВТОРЫ: '{match.group(0)}' ({count}x)", log_type="BANWORDS")
|
||||||
|
return {"word": f"'{match.group(0)}' ({count}x)", "type": "repeated_chars"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _handle_spam(self, message: Message, spam_result: Dict[str, str]) -> None:
|
||||||
|
"""Обрабатывает спам-сообщение: удаляет, логирует, уведомляет (кроме silence)"""
|
||||||
user = message.from_user
|
user = message.from_user
|
||||||
matched_word = spam_result["word"]
|
matched_word = spam_result["word"]
|
||||||
match_type = spam_result["type"]
|
match_type = spam_result["type"]
|
||||||
|
|
||||||
# Получаем текст сообщения
|
|
||||||
message_text = message.text or message.caption or "[нет текста]"
|
message_text = message.text or message.caption or "[нет текста]"
|
||||||
|
|
||||||
# === 1. УДАЛЯЕМ СООБЩЕНИЕ ===
|
# В режиме тишины удаляем молча
|
||||||
|
if match_type == "silence":
|
||||||
try:
|
try:
|
||||||
await message.delete()
|
await message.delete()
|
||||||
logger.info(
|
logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча", log_type="BANWORDS")
|
||||||
f"Удалено сообщение от @{user.username or user.id} "
|
|
||||||
f"(слово: '{matched_word}', тип: {match_type})",
|
|
||||||
log_type="BANWORDS",
|
|
||||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
|
||||||
)
|
|
||||||
except TelegramBadRequest as e:
|
except TelegramBadRequest as e:
|
||||||
logger.error(
|
logger.error(f"❌ Не удалено (silence): {e}", log_type="BANWORDS")
|
||||||
f"Не удалось удалить сообщение: {e}",
|
|
||||||
log_type="BANWORDS",
|
|
||||||
user=f"@{user.username}" if user.username else f"id{user.id}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# === 2. ЛОГИРУЕМ В БД ===
|
# Удаляем сообщение
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
logger.info(f"🚫 @{user.username or user.id}: '{matched_word}' ({match_type})", log_type="BANWORDS")
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.error(f"❌ Не удалено: {e}", log_type="BANWORDS")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Логируем в БД
|
||||||
await self.manager.log_spam(
|
await self.manager.log_spam(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
username=user.username or f"id{user.id}",
|
username=user.username or f"id{user.id}",
|
||||||
@@ -294,7 +275,7 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
match_type=match_type
|
match_type=match_type
|
||||||
)
|
)
|
||||||
|
|
||||||
# === 3. УВЕДОМЛЯЕМ АДМИНОВ ===
|
# Уведомляем админов
|
||||||
await self._notify_admins(message, matched_word, match_type, message_text)
|
await self._notify_admins(message, matched_word, match_type, message_text)
|
||||||
|
|
||||||
async def _notify_admins(
|
async def _notify_admins(
|
||||||
@@ -304,86 +285,63 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
match_type: str,
|
match_type: str,
|
||||||
message_text: str
|
message_text: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Отправляет уведомление об удалении в админ-чат (берёт ID из БД)"""
|
||||||
Отправляет уведомление в админский чат с кнопками.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Удалённое сообщение
|
|
||||||
matched_word: Слово, по которому сработал фильтр
|
|
||||||
match_type: Тип проверки
|
|
||||||
message_text: Текст сообщения
|
|
||||||
"""
|
|
||||||
user = message.from_user
|
user = message.from_user
|
||||||
username = f"@{user.username}" if user.username else f"ID: {user.id}"
|
username = f"@{user.username}" if user.username else f"ID: {user.id}"
|
||||||
|
|
||||||
# Получаем количество предыдущих нарушений
|
|
||||||
spam_count = await self.manager.get_user_spam_count(user.id)
|
spam_count = await self.manager.get_user_spam_count(user.id)
|
||||||
|
chat_title = message.chat.title or "Без названия"
|
||||||
|
source_thread_id = message.message_thread_id
|
||||||
|
|
||||||
# Формируем текст уведомления
|
|
||||||
notification_text = (
|
notification_text = (
|
||||||
f"🚫 <b>Удалено сообщение</b>\n\n"
|
f"🚫 <b>Удалено сообщение</b>\n\n"
|
||||||
f"👤 <b>Пользователь:</b> {username}\n"
|
f"👤 <b>Пользователь:</b> {username}\n"
|
||||||
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
|
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
|
||||||
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
|
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
|
||||||
f"🔍 <b>Триггер:</b> <code>{matched_word}</code>\n"
|
f"💬 <b>Чат:</b> {self._escape_html(chat_title)}\n"
|
||||||
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {match_type}\n\n"
|
f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
|
||||||
f"💬 <b>Текст:</b>\n"
|
f"{'📌 <b>Topic ID:</b> <code>' + str(source_thread_id) + '</code>\n' if source_thread_id else ''}"
|
||||||
f"<code>{self._escape_html(message_text[:500])}</code>"
|
f"🔗 <b>Message ID:</b> <code>{message.message_id}</code>\n\n"
|
||||||
|
f"🔍 <b>Триггер:</b> <code>{self._escape_html(matched_word)}</code>\n"
|
||||||
|
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\n\n"
|
||||||
|
f"💬 <b>Текст:</b>\n<code>{self._escape_html(message_text[:500])}</code>"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаём клавиатуру с действиями
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(text="🔨 Забанить", callback_data=f"spam_ban:{user.id}:{message.chat.id}"),
|
||||||
text="🔨 Забанить",
|
InlineKeyboardButton(text="✅ Закрыть", callback_data="spam_close")
|
||||||
callback_data=f"spam_ban:{user.id}:{message.chat.id}"
|
|
||||||
),
|
|
||||||
InlineKeyboardButton(
|
|
||||||
text="✅ Закрыть",
|
|
||||||
callback_data="spam_close"
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
[
|
[InlineKeyboardButton(text="📊 Статистика", callback_data=f"spam_stats:{user.id}")]
|
||||||
InlineKeyboardButton(
|
|
||||||
text="📊 Статистика",
|
|
||||||
callback_data=f"spam_stats:{user.id}"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
])
|
])
|
||||||
|
|
||||||
# Отправляем уведомление
|
|
||||||
try:
|
try:
|
||||||
bot = message.bot
|
# ✅ Получаем настройки из БД (динамические, установленные через /settings)
|
||||||
await bot.send_message(
|
admin_chat_id = await self.manager.get_bot_setting("admin_chat_id")
|
||||||
chat_id=settings.ADMIN_CHAT_ID,
|
admin_thread_id = await self.manager.get_bot_setting("admin_thread_id")
|
||||||
|
|
||||||
|
if admin_chat_id:
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=int(admin_chat_id),
|
||||||
text=notification_text,
|
text=notification_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML",
|
||||||
|
message_thread_id=int(admin_thread_id) if admin_thread_id else None
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS")
|
||||||
f"Ошибка отправки уведомления админам: {e}",
|
|
||||||
log_type="BANWORDS"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_type_emoji(match_type: str) -> str:
|
def _get_type_emoji(match_type: str) -> str:
|
||||||
"""Возвращает эмодзи для типа проверки"""
|
return {
|
||||||
emoji_map = {
|
|
||||||
"substring": "🔤",
|
"substring": "🔤",
|
||||||
"lemma": "📖",
|
"lemma": "📖",
|
||||||
"part": "🧩",
|
"part": "🧩",
|
||||||
"silence": "🔇",
|
"silence": "🔇",
|
||||||
"conflict_substring": "⚔️",
|
"conflict_substring": "⚔️",
|
||||||
"conflict_lemma": "⚔️"
|
"conflict_lemma": "⚔️",
|
||||||
}
|
"repeated_chars": "🔁"
|
||||||
return emoji_map.get(match_type, "❓")
|
}.get(match_type, "❓")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _escape_html(text: str) -> str:
|
def _escape_html(text: str) -> str:
|
||||||
"""Экранирует HTML символы для безопасного отображения"""
|
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
return (
|
|
||||||
text.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ class UserSpamStats:
|
|||||||
"""Удаляет старые запросы за пределами временного окна"""
|
"""Удаляет старые запросы за пределами временного окна"""
|
||||||
cutoff_time = current_time - time_window
|
cutoff_time = current_time - time_window
|
||||||
|
|
||||||
# Удаляем старые запросы
|
|
||||||
new_times = []
|
new_times = []
|
||||||
new_contexts = []
|
new_contexts = []
|
||||||
|
|
||||||
@@ -121,7 +120,6 @@ class UserSpamStats:
|
|||||||
current_time = time()
|
current_time = time()
|
||||||
|
|
||||||
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
||||||
# Если 5+ сообщений за 2 секунды => мгновенный мут
|
|
||||||
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
|
very_recent = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 2.0]
|
||||||
if len(very_recent) >= 5:
|
if len(very_recent) >= 5:
|
||||||
return {
|
return {
|
||||||
@@ -130,7 +128,7 @@ class UserSpamStats:
|
|||||||
'severity': 1.0,
|
'severity': 1.0,
|
||||||
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
|
'details': f"⚡ Экстремальный флуд: {len(very_recent)} сообщений за 2 секунды",
|
||||||
'instant_block': True,
|
'instant_block': True,
|
||||||
'block_duration': 600.0 # 10 минут сразу
|
'block_duration': 600.0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
||||||
@@ -142,13 +140,12 @@ class UserSpamStats:
|
|||||||
'severity': 0.95,
|
'severity': 0.95,
|
||||||
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
|
'details': f"🔥 Агрессивный флуд: {len(recent_5s)} сообщений за 5 секунд",
|
||||||
'instant_block': True,
|
'instant_block': True,
|
||||||
'block_duration': 300.0 # 5 минут
|
'block_duration': 300.0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Медиа-флуд (стикеры/фото/видео)
|
# 3. Медиа-флуд
|
||||||
media_contexts = [ctx for ctx in recent_contexts if ctx.media_type]
|
media_contexts = [ctx for ctx in recent_contexts if ctx.media_type]
|
||||||
if len(media_contexts) >= 7:
|
if len(media_contexts) >= 7:
|
||||||
# Проверяем скорость отправки медиа
|
|
||||||
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
|
media_recent = [ctx for ctx in media_contexts if (current_time - ctx.timestamp) < 5.0]
|
||||||
if len(media_recent) >= 6:
|
if len(media_recent) >= 6:
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +154,7 @@ class UserSpamStats:
|
|||||||
'severity': 0.9,
|
'severity': 0.9,
|
||||||
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
|
'details': f"📸 Медиа-флуд: {len(media_recent)} файлов за 5 секунд",
|
||||||
'instant_block': True,
|
'instant_block': True,
|
||||||
'block_duration': 240.0 # 4 минуты
|
'block_duration': 240.0
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,14 +170,14 @@ class UserSpamStats:
|
|||||||
text_counts = Counter(texts)
|
text_counts = Counter(texts)
|
||||||
most_common_text, count = text_counts.most_common(1)[0]
|
most_common_text, count = text_counts.most_common(1)[0]
|
||||||
|
|
||||||
if count >= 5: # 5 одинаковых сообщений
|
if count >= 5:
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
'reason': 'identical_messages',
|
'reason': 'identical_messages',
|
||||||
'severity': 0.85,
|
'severity': 0.85,
|
||||||
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
|
'details': f"📋 Повтор: '{most_common_text[:40]}...' ({count}x)",
|
||||||
'instant_block': True,
|
'instant_block': True,
|
||||||
'block_duration': 180.0 # 3 минуты
|
'block_duration': 180.0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 5. Проверка спама callback кнопок
|
# 5. Проверка спама callback кнопок
|
||||||
@@ -189,14 +186,14 @@ class UserSpamStats:
|
|||||||
callback_counts = Counter(callbacks)
|
callback_counts = Counter(callbacks)
|
||||||
most_common_callback, count = callback_counts.most_common(1)[0]
|
most_common_callback, count = callback_counts.most_common(1)[0]
|
||||||
|
|
||||||
if count >= 10: # 10 нажатий одной кнопки
|
if count >= 10:
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
'reason': 'callback_spam',
|
'reason': 'callback_spam',
|
||||||
'severity': 0.8,
|
'severity': 0.8,
|
||||||
'details': f"🔘 Спам кнопки: {count} нажатий",
|
'details': f"🔘 Спам кнопки: {count} нажатий",
|
||||||
'instant_block': True,
|
'instant_block': True,
|
||||||
'block_duration': 120.0 # 2 минуты
|
'block_duration': 120.0
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
return {'is_spam': False, 'reason': None, 'severity': 0.0}
|
||||||
@@ -269,11 +266,11 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
- Детекция скорости отправки сообщений
|
- Детекция скорости отправки сообщений
|
||||||
- Адаптивная длительность блокировки
|
- Адаптивная длительность блокировки
|
||||||
- Различает типы активности
|
- Различает типы активности
|
||||||
|
- Бот никогда не банит сам себя
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
# Базовые лимиты (мягкие, для накопления варнингов)
|
|
||||||
rate_limit_text: int = 8,
|
rate_limit_text: int = 8,
|
||||||
rate_limit_forward: int = 20,
|
rate_limit_forward: int = 20,
|
||||||
rate_limit_callback: int = 12,
|
rate_limit_callback: int = 12,
|
||||||
@@ -281,12 +278,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
time_window: float = 10.0,
|
time_window: float = 10.0,
|
||||||
|
|
||||||
# Предупреждения (уже не так важны — флуд блокируется мгновенно)
|
|
||||||
warning_limit: int = 3,
|
warning_limit: int = 3,
|
||||||
base_block_duration: float = 120.0, # 2 минуты за накопленные варнинги
|
base_block_duration: float = 120.0,
|
||||||
max_block_duration: float = 3600.0,
|
max_block_duration: float = 3600.0,
|
||||||
|
|
||||||
# Опции
|
|
||||||
whitelist_admins: bool = True,
|
whitelist_admins: bool = True,
|
||||||
progressive_blocking: bool = True,
|
progressive_blocking: bool = True,
|
||||||
enable_smart_detection: bool = True,
|
enable_smart_detection: bool = True,
|
||||||
@@ -318,7 +313,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
context.is_reply = event.reply_to_message is not None
|
context.is_reply = event.reply_to_message is not None
|
||||||
context.is_command = bool(context.text and context.text.startswith('/'))
|
context.is_command = bool(context.text and context.text.startswith('/'))
|
||||||
|
|
||||||
# Определяем тип медиа
|
|
||||||
if event.photo:
|
if event.photo:
|
||||||
context.media_type = 'photo'
|
context.media_type = 'photo'
|
||||||
elif event.video:
|
elif event.video:
|
||||||
@@ -350,7 +344,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
else:
|
else:
|
||||||
base_limit = self.rate_limit_text
|
base_limit = self.rate_limit_text
|
||||||
|
|
||||||
# Применяем репутацию
|
|
||||||
if self.enable_reputation:
|
if self.enable_reputation:
|
||||||
base_limit = int(base_limit * user_stats.reputation)
|
base_limit = int(base_limit * user_stats.reputation)
|
||||||
|
|
||||||
@@ -392,6 +385,11 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
if user_id is None:
|
if user_id is None:
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# ✅ ИСПРАВЛЕНИЕ: пропускаем самого бота (предотвращает самобан)
|
||||||
|
bot = data.get("bot")
|
||||||
|
if bot and user_id == bot.id:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
|
||||||
|
|
||||||
# Whitelist для администраторов
|
# Whitelist для администраторов
|
||||||
@@ -414,7 +412,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# НЕ отправляем сообщение каждый раз — только callback answer
|
# Только для callback — отвечаем алертом, для сообщений молчим
|
||||||
if isinstance(event, CallbackQuery):
|
if isinstance(event, CallbackQuery):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
f"🚫 Блокировка: {self._format_duration(remaining)}",
|
f"🚫 Блокировка: {self._format_duration(remaining)}",
|
||||||
@@ -426,10 +424,10 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
# Извлекаем контекст сообщения
|
# Извлекаем контекст сообщения
|
||||||
context = self._extract_context(event)
|
context = self._extract_context(event)
|
||||||
|
|
||||||
# Добавляем запрос СНАЧАЛА (важно для детекции скорости)
|
# Добавляем запрос СНАЧАЛА — важно для детекции скорости флуда
|
||||||
user_stats.add_request(current_time, context)
|
user_stats.add_request(current_time, context)
|
||||||
|
|
||||||
# Очищаем старые запросы
|
# Очищаем старые запросы за пределами временного окна
|
||||||
user_stats.clean_old_requests(current_time, self.time_window)
|
user_stats.clean_old_requests(current_time, self.time_window)
|
||||||
|
|
||||||
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
|
# ========== КРИТИЧНО: МГНОВЕННАЯ ДЕТЕКЦИЯ ФЛУДА ==========
|
||||||
@@ -437,10 +435,9 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
spam_analysis = user_stats.detect_spam_patterns(self.time_window)
|
spam_analysis = user_stats.detect_spam_patterns(self.time_window)
|
||||||
|
|
||||||
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
|
if spam_analysis.get('is_spam') and spam_analysis.get('instant_block'):
|
||||||
# МГНОВЕННАЯ БЛОКИРОВКА
|
|
||||||
block_duration = spam_analysis.get('block_duration', 300.0)
|
block_duration = spam_analysis.get('block_duration', 300.0)
|
||||||
user_stats.block(current_time, block_duration)
|
user_stats.block(current_time, block_duration)
|
||||||
user_stats.warnings = self.warning_limit # Максимум варнингов
|
user_stats.warnings = self.warning_limit
|
||||||
spam_stats.instant_blocks += 1
|
spam_stats.instant_blocks += 1
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -461,7 +458,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
try:
|
try:
|
||||||
await event.answer(block_message, parse_mode="HTML")
|
await event.answer(block_message, parse_mode="HTML")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
@@ -471,10 +468,9 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ (для мягких превышений) ==========
|
# ========== ОБЫЧНАЯ ПРОВЕРКА ЛИМИТОВ ==========
|
||||||
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
effective_limit = self._get_effective_rate_limit(user_stats, context)
|
||||||
|
|
||||||
# Подсчитываем релевантные запросы
|
|
||||||
relevant_requests = 0
|
relevant_requests = 0
|
||||||
for req_context in user_stats.message_contexts:
|
for req_context in user_stats.message_contexts:
|
||||||
if context.is_forward and req_context.is_forward:
|
if context.is_forward and req_context.is_forward:
|
||||||
@@ -493,7 +489,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# Мягкое превышение лимита
|
|
||||||
if relevant_requests >= effective_limit:
|
if relevant_requests >= effective_limit:
|
||||||
user_stats.add_warning()
|
user_stats.add_warning()
|
||||||
spam_stats.total_warnings_issued += 1
|
spam_stats.total_warnings_issued += 1
|
||||||
@@ -505,7 +500,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
user=user_str
|
user=user_str
|
||||||
)
|
)
|
||||||
|
|
||||||
# Блокировка при достижении лимита варнингов
|
|
||||||
if user_stats.warnings >= self.warning_limit:
|
if user_stats.warnings >= self.warning_limit:
|
||||||
block_duration = self._calculate_block_duration(user_stats.warnings)
|
block_duration = self._calculate_block_duration(user_stats.warnings)
|
||||||
user_stats.block(current_time, block_duration)
|
user_stats.block(current_time, block_duration)
|
||||||
@@ -526,7 +520,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
try:
|
try:
|
||||||
await event.answer(block_message, parse_mode="HTML")
|
await event.answer(block_message, parse_mode="HTML")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
await event.answer(
|
await event.answer(
|
||||||
@@ -536,7 +530,6 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Предупреждение (только для сообщений, не для callback)
|
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
warning_message = (
|
warning_message = (
|
||||||
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
|
f"⚠️ <b>Предупреждение {user_stats.warnings}/{self.warning_limit}</b>\n\n"
|
||||||
@@ -544,7 +537,7 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await event.answer(warning_message, parse_mode="HTML")
|
await event.answer(warning_message, parse_mode="HTML")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ from .auto_delete import *
|
|||||||
# ================= DECORATORS =================
|
# ================= DECORATORS =================
|
||||||
from .decorators import *
|
from .decorators import *
|
||||||
|
|
||||||
|
from .telegram_emoji import *
|
||||||
|
|||||||
4
bot/utils/telegram_emoji.py
Normal file
4
bot/utils/telegram_emoji.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
def tg_emoji(id: str, emoji: str = "💠") -> str:
|
||||||
|
"""Генерирует HTML-тег кастомного эмодзи."""
|
||||||
|
return f'<tg-emoji emoji-id="{id}">{emoji}</tg-emoji>'
|
||||||
|
|
||||||
@@ -11,6 +11,12 @@ COMMANDS: Final[dict[str, list[str]]] = {
|
|||||||
"st", "on", "вкл", # сокращения
|
"st", "on", "вкл", # сокращения
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"stop": [
|
||||||
|
"stop", "стоп", "завершить", # основные
|
||||||
|
"off", "ыещз", "cnjg", "pfdthibnm", # раскладка + сокращение
|
||||||
|
"щаа", # сокращения
|
||||||
|
],
|
||||||
|
|
||||||
"help": [
|
"help": [
|
||||||
"help", "помощь", "допомога", # основные
|
"help", "помощь", "допомога", # основные
|
||||||
"рудз", "gjvjom", "ljgjvjuf", # раскладка
|
"рудз", "gjvjom", "ljgjvjuf", # раскладка
|
||||||
@@ -30,335 +36,329 @@ COMMANDS: Final[dict[str, list[str]]] = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ====================
|
# ==================== ДОБАВЛЕНИЕ ПОСТОЯННЫХ ====================
|
||||||
|
|
||||||
"addword": [
|
"addword": [
|
||||||
"addword", "добавитьслово", # основные
|
"addword", "добавитьслово", # основные
|
||||||
"фввцщкв", "lj,fdbnmckjdj", # раскладка
|
"фввцщкв", "lj,fdbnmckjdj", # раскладка
|
||||||
"aw", "addw", "добслово", # сокращения
|
"aw", "addw", "добслово", "word", # сокращения
|
||||||
],
|
],
|
||||||
|
|
||||||
"addlemma": [
|
"addlemma": [
|
||||||
"addlemma", "добавитьлемму", # основные
|
"addlemma", "добавитьлемму", # основные
|
||||||
"фввдуььф", "lj,fdbnmktve", # раскладка
|
"фввдуььф", "lj,fdbnmktve", # раскладка
|
||||||
"al", "addl", "доблемму", # сокращения
|
"al", "addl", "доблемму", "lemma", "lem", "lema",
|
||||||
],
|
],
|
||||||
|
|
||||||
"addpart": [
|
"addpart": [
|
||||||
"addpart", "добавитьчасть", # основные
|
"addpart", "добавитьчасть", # основные
|
||||||
"фввзфке", "lj,fdbnmxfcnm", # раскладка
|
"фввзфке", "lj,fdbnmxfcnm", # раскладка
|
||||||
"ap", "addp", "добчасть", # сокращения
|
"ap", "addp", "добчасть", "part",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ====================
|
# ==================== ДОБАВЛЕНИЕ ВРЕМЕННЫХ ====================
|
||||||
|
|
||||||
"addtempword": [
|
"addtempword": [
|
||||||
"addtempword", "добавитьвремслово", # основные
|
"addtempword", "добавитьвремслово", # основные
|
||||||
"фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка
|
"фввеуьзцщкв", "lj,fdbnmdhtvckjdj", # раскладка
|
||||||
"atw", "addtw", "темпслово", # сокращения
|
"atw", "addtw", "темпслово", "addtword", "tempword", "tword",
|
||||||
],
|
],
|
||||||
|
|
||||||
"addtemplemma": [
|
"addtemplemma": [
|
||||||
"addtemplemma", "добавитьвремлемму", # основные
|
"addtemplemma", "добавитьвремлемму", # основные
|
||||||
"фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка
|
"фввеуьздуььф", "lj,fdbnmdhtvktve", # раскладка
|
||||||
"atl", "addtl", "темплемму", # сокращения
|
"atl", "addtl", "темплемму", "addtlem", "addtemplem",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
|
# ==================== ДОБАВЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
|
||||||
|
|
||||||
"addexcept": [
|
"addexcept": [
|
||||||
"addexcept", "добавитьисключение", # основные
|
"addexcept", "добавитьисключение", # основные
|
||||||
"фввучсузе", "lj,fdbnmbcrkx", # раскладка
|
"фввучсузе", "lj,fdbnmbcrkx", # раскладка
|
||||||
"axc", "addwhite", "искл", # сокращения
|
"axc", "addwhite", "искл", "except", "white",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ====================
|
# ==================== УДАЛЕНИЕ ПОСТОЯННЫХ ====================
|
||||||
|
|
||||||
"remword": [
|
"remword": [
|
||||||
"remword", "удалитьслово", # основные
|
"remword", "удалитьслово", # основные
|
||||||
"кутцщкв", "elfkbnmckjdj", # раскладка
|
"кутцщкв", "elfkbnmckjdj", # раскладка
|
||||||
"rw", "delword", "dw", "удслово", # сокращения
|
"rw", "delword", "dw", "удслово",
|
||||||
],
|
],
|
||||||
|
|
||||||
"remlemma": [
|
"remlemma": [
|
||||||
"remlemma", "удалитьлемму", # основные
|
"remlemma", "удалитьлемму", # основные
|
||||||
"кутдуььф", "elfkbnmktve", # раскладка
|
"кутдуььф", "elfkbnmktve", # раскладка
|
||||||
"rl", "dellemma", "dl", "удлемму", # сокращения
|
"rl", "dellemma", "dl", "удлемму",
|
||||||
],
|
],
|
||||||
|
|
||||||
"rempart": [
|
"rempart": [
|
||||||
"rempart", "удалитьчасть", # основные
|
"rempart", "удалитьчасть", # основные
|
||||||
"кутзфке", "elfkbnmxfcnm", # раскладка
|
"кутзфке", "elfkbnmxfcnm", # раскладка
|
||||||
"rp", "delpart", "dp", "удчасть", # сокращения
|
"rp", "delpart", "dp", "удчасть",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ====================
|
# ==================== УДАЛЕНИЕ ВРЕМЕННЫХ ====================
|
||||||
|
|
||||||
"remtempword": [
|
"remtempword": [
|
||||||
"remtempword", "удалитьвремслово", # основные
|
"remtempword", "удалитьвремслово", # основные
|
||||||
"кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка
|
"кутеуьзцщкв", "elfkbnmdhtvckjdj", # раскладка
|
||||||
"rtw", "deltw", "удтемпслово", # сокращения
|
"rtw", "deltw", "удтемпслово", "rtword", "rtempword",
|
||||||
],
|
],
|
||||||
|
|
||||||
"remtemplemma": [
|
"remtemplemma": [
|
||||||
"remtemplemma", "удалитьвремлемму", # основные
|
"remtemplemma", "удалитьвремлемму", # основные
|
||||||
"кутеуьздуььф", "elfkbnmdhtvktve", # раскладка
|
"кутеуьздуььф", "elfkbnmdhtvktve", # раскладка
|
||||||
"rtl", "deltl", "удтемплемму", # сокращения
|
"rtl", "deltl", "удтемплемму", "rtlemma", "rtemplemma", "rtlem",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
|
# ==================== УДАЛЕНИЕ ИСКЛЮЧЕНИЙ ====================
|
||||||
|
|
||||||
"remexcept": [
|
"remexcept": [
|
||||||
"remexcept", "удалитьисключение", # основные
|
"remexcept", "удалитьисключение", # основные
|
||||||
"кутучсузе", "elfkbnmbcrkx", # раскладка
|
"кутучсузе", "elfkbnmbcrkx", # раскладка
|
||||||
"rxc", "remwhite", "удискл", # сокращения
|
"rxc", "remwhite", "удискл",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== КОНФЛИКТНЫЕ СЛОВА ====================
|
# ==================== КОНФЛИКТНЫЕ СЛОВА ====================
|
||||||
|
|
||||||
"addconflictword": [
|
"addconflictword": [
|
||||||
"addconflictword", "добавитьконфликт", # основные
|
"addconflictword", "добавитьконфликт", # основные
|
||||||
"фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка
|
"фввсщтакшсецщкв", "lj,fdbnmrjyakbrn", # раскладка
|
||||||
"acw", "addcw", "конфслово", # сокращения
|
"acw", "addcw", "конфслово", "conflictword",
|
||||||
],
|
],
|
||||||
|
|
||||||
"addconflictlemma": [
|
"addconflictlemma": [
|
||||||
"addconflictlemma", "добавитьконфлемму", # основные
|
"addconflictlemma", "добавитьконфлемму", # основные
|
||||||
"фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка
|
"фввсщтакшседуььф", "lj,fdbnmrjyaktve", # раскладка
|
||||||
"acl", "addcl", "конфлемму", # сокращения
|
"acl", "addcl", "конфлемму", "conflictlemma",
|
||||||
],
|
],
|
||||||
|
|
||||||
"remconflictword": [
|
"remconflictword": [
|
||||||
"remconflictword", "удалитьконфликт", # основные
|
"remconflictword", "удалитьконфликт", # основные
|
||||||
"кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка
|
"кутсщтакшсецщкв", "elfkbnmrjyakbrn", # раскладка
|
||||||
"rcw", "delcw", "удконфликт", # сокращения
|
"rcw", "delcw", "удконфликт",
|
||||||
],
|
],
|
||||||
|
|
||||||
"remconflictlemma": [
|
"remconflictlemma": [
|
||||||
"remconflictlemma", "удалитьконфлемму", # основные
|
"remconflictlemma", "удалитьконфлемму", # основные
|
||||||
"кутсщтakшседуььф", "elfkbnmrjyaktve", # раскладка
|
"кутсщтакшседуььф", "elfkbnmrjyaktve", # раскладка
|
||||||
"rcl", "delcl", "удконфлемму", # сокращения
|
"rcl", "delcl", "удконфлемму",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== РЕЖИМ АНТИКОНФЛИКТА ====================
|
# ==================== РЕЖИМ АНТИКОНФЛИКТА ====================
|
||||||
|
|
||||||
"stopconflict": [
|
"stopconflict": [
|
||||||
"stopconflict", "стопконфликт", # основные
|
"stopconflict", "стопконфликт", # основные
|
||||||
"cnjgsщтakшse", "cnjzrjyakbrn", # раскладка
|
"cnjgsщтакшse", "cnjzrjyakbrn", # раскладка
|
||||||
"sconf", "sc", "стопконф", # сокращения
|
"sconf", "sc", "стопконф", "stopconf",
|
||||||
],
|
],
|
||||||
|
|
||||||
"unstopconflict": [
|
"unstopconflict": [
|
||||||
"unstopconflict", "отменаконфликта", # основные
|
"unstopconflict", "отменаконфликта", # основные
|
||||||
"eycnjgsщтakшse", "jnvtyf", # раскладка
|
"eycnjgsщтакшse", "jnvtyf", # раскладка
|
||||||
"usconf", "usc", "откконф", # сокращения
|
"usconf", "usc", "откконф", "unstopconf",
|
||||||
],
|
],
|
||||||
|
|
||||||
"conflictstatus": [
|
"conflictstatus": [
|
||||||
"conflictstatus", "статусконфликта", # основные
|
"conflictstatus", "статусконфликта", # основные
|
||||||
"сщтakшseыефnec", "cnfnec", # раскладка
|
"сщтакшseыефnec", "cnfnec", # раскладка
|
||||||
"cstatus", "cs", "статконф", # сокращения
|
"cstatus", "cs", "статконф", "confstat",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== РЕЖИМ ТИШИНЫ ====================
|
# ==================== РЕЖИМ ТИШИНЫ ====================
|
||||||
|
|
||||||
"silence": [
|
"silence": [
|
||||||
"silence", "тишина", "мут", # основные
|
"silence", "тишина", # основные
|
||||||
"ышдутсу", "nbibyf", "ven", # раскладка
|
"ышдутсу", "nbibyf", # раскладка
|
||||||
"sil", "mute", "quiet", "тиш", # сокращения
|
"sl", "sil", "mute", "quiet", "тиш", "ven",
|
||||||
],
|
],
|
||||||
|
|
||||||
"unsilence": [
|
"unsilence": [
|
||||||
"unsilence", "отменатишины", # основные
|
"unsilence", "отменатишины", # основные
|
||||||
"eтышдутсу", "jnvtyf", # раскладка
|
"eышдутсу", "jnvtyf", # раскладка
|
||||||
"unsil", "unmute", "откмут", # сокращения
|
"unsil", "unmute", "откмут", "usl", "unsl",
|
||||||
],
|
],
|
||||||
|
|
||||||
"silencestatus": [
|
"silencestatus": [
|
||||||
"silencestatus", "статустишины", # основные
|
"silencestatus", "статустишины", # основные
|
||||||
"ышдутсуыефnec", "cnfnec", # раскладка
|
"ышдутсуыефnec", "cnfnec", # раскладка
|
||||||
"sstatus", "ss", "статтиш", # сокращения
|
"sstatus", "ss", "статтиш",
|
||||||
],
|
],
|
||||||
|
|
||||||
"extend_silence": [
|
"extend_silence": [
|
||||||
"extend_silence", "продлитьтишину", # основные
|
"extend_silence", "продлитьтишину", # основные
|
||||||
"уче_ышдутсу", "ghjlkbnmnbibyet", # раскладка
|
"ex_ышдутсу", "ghjlkbnmnbibyet", # раскладка
|
||||||
"exsil", "exs", "продтиш", # сокращения
|
"exsil", "exs", "продтиш",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== АДМИНИСТРАТОРЫ ====================
|
# ==================== АДМИНИСТРАТОРЫ ====================
|
||||||
|
|
||||||
"addadmin": [
|
"addadmin": [
|
||||||
"addadmin", "добавитьадмина", # основные
|
"addadmin", "добавитьадмина", # основные
|
||||||
"фввфвьшт", "lj,fdbnmflvbyf", # раскладка
|
"фввфвьшт", "lj,fdbnmflvbyf", # раскладка
|
||||||
"aa", "addadm", "добадм", # сокращения
|
"aa", "addadm", "добадм",
|
||||||
],
|
],
|
||||||
|
|
||||||
"remadmin": [
|
"remadmin": [
|
||||||
"remadmin", "удалитьадмина", # основные
|
"remadmin", "удалитьадмина", # основные
|
||||||
"кутфвьшт", "elfkbnmflvbyf", # раскладка
|
"кутфвьшт", "elfkbnmflvbyf", # раскладка
|
||||||
"ra", "remadm", "deladmin", "удадм", # сокращения
|
"ra", "remadm", "deladmin", "удадм",
|
||||||
],
|
],
|
||||||
|
|
||||||
"listadmins": [
|
"listadmins": [
|
||||||
"listadmins", "списокадминов", # основные
|
"listadmins", "списокадминов", # основные
|
||||||
"дшыефвьшты", "cgbcjrflvbyjd", # раскладка
|
"дшыефвьшты", "cgbcjrflvbyjd", # раскладка
|
||||||
"admins", "adm", "adminlist", "адм", # сокращения
|
"admins", "adm", "adminlist", "адм", "дшыефвь", "listadm", "la",
|
||||||
],
|
],
|
||||||
|
|
||||||
"adminhelp": [
|
"adminhelp": [
|
||||||
"adminhelp", "помощьадмину", # основные
|
"adminhelp", "помощьадмину", # основные
|
||||||
"фвьштрудз", "gjvjomflvbyt", # раскладка
|
"фвьштрудз", "gjvjomflvbyt", # раскладка
|
||||||
"admhelp", "ah", "хелпадм", # сокращения
|
"admhelp", "ah", "хелпадм",
|
||||||
],
|
],
|
||||||
|
|
||||||
"checkadmin": [
|
"checkadmin": [
|
||||||
"checkadmin", "проверкаадмина", # основные
|
"checkadmin", "проверкаадмина", # основные
|
||||||
"сруслфвьшт", "ghjdthrf", # раскладка
|
"сруслфвьшт", "ghjdthrf", # раскладка
|
||||||
"isadmin", "ca", "провадм", # сокращения
|
"isadmin", "ca", "провадм", "checkadm",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ПРОСМОТР ====================
|
# ==================== ПРОСМОТР ====================
|
||||||
|
|
||||||
"list": [
|
"list": [
|
||||||
"listwords", "списокслов", # основные
|
"listwords", "списокслов", "listword", # основные
|
||||||
"дшыецщквы", "cgbcjrckjd", # раскладка
|
"дшыецщквы", "cgbcjrckjd", # раскладка
|
||||||
"lw", "list", "дшые", "words", "слова", # сокращения
|
"lw", "list", "дшые", "words", "слова", "l",
|
||||||
],
|
],
|
||||||
|
|
||||||
"listlemmas": [
|
"listlemmas": [
|
||||||
"listlemmas", "списоклемм", # основные
|
"listlemmas", "списоклемм", # основные
|
||||||
"дшыедуььфы", "cgbcjrktv", # раскладка
|
"дшыедуььфы", "cgbcjrktv", # раскладка
|
||||||
"ll", "lemmas", "леммы", # сокращения
|
"ll", "lemmas", "леммы",
|
||||||
],
|
],
|
||||||
|
|
||||||
"listparts": [
|
"listparts": [
|
||||||
"listparts", "списокчастей", # основные
|
"listparts", "списокчастей", # основные
|
||||||
"дшыезфкеы", "cgbcjrxfcntq", # раскладка
|
"дшыезфкеы", "cgbcjrxfcntq", # раскладка
|
||||||
"lp", "parts", "части", # сокращения
|
"lp", "parts", "части",
|
||||||
],
|
],
|
||||||
|
|
||||||
"listexcept": [
|
"listexcept": [
|
||||||
"listexcept", "списокисключений", # основные
|
"listexcept", "списокисключений", # основные
|
||||||
"дшыеучсузе", "cgbcjrbcrkx", # раскладка
|
"дшыеучсузе", "cgbcjrbcrkx", # раскладка
|
||||||
"lxc", "except", "white", "искл", # сокращения
|
"lxc", "except", "white", "искл",
|
||||||
],
|
],
|
||||||
|
|
||||||
"listconflict": [
|
"listconflict": [
|
||||||
"listconflict", "списокконфликтов", # основные
|
"listconflict", "списокконфликтов", # основные
|
||||||
"дшыесщтakшse", "cgbcjrrjyakbrnjd", # раскладка
|
"дшыесщтакшse", "cgbcjrrjyakbrnjd", # раскладка
|
||||||
"lc", "conflict", "конф", # сокращения
|
"lc", "conflict", "конф",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== СТАТИСТИКА ====================
|
# ==================== СТАТИСТИКА ====================
|
||||||
|
|
||||||
"userstats": [
|
"userstats": [
|
||||||
"userstats", "статистикапользователя", # основные
|
"userstats", "статистикапользователя", # основные
|
||||||
"ecthыефnы", "cnfnbcnbrf", # раскладка
|
"ecthыефnы", "cnfnbcnbrf", # раскладка
|
||||||
"ustat", "us", "статюзер", # сокращения
|
"ustat", "us", "статюзер",
|
||||||
],
|
],
|
||||||
|
|
||||||
"resetstats": [
|
"resetstats": [
|
||||||
"resetstats", "сброситьстат", # основные
|
"resetstats", "сброситьстат", # основные
|
||||||
"кыуеыефnы", "c,hjcbnm", # раскладка
|
"кыуеыефnы", "c,hjcbnm", # раскладка
|
||||||
"rstats", "clearstats", "сброс", # сокращения
|
"rstats", "clearstats", "сброс",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ИНФОРМАЦИЯ ====================
|
# ==================== ИНФОРМАЦИЯ ====================
|
||||||
|
|
||||||
"id": [
|
"id": [
|
||||||
"id", "айди", "инфо", # основные
|
"id", "айди", "инфо", # основные
|
||||||
"шв", "fqlb", "byaj", # раскладка
|
"шв", "fqlb", "byaj", # раскладка
|
||||||
"info", "me", "мои", # сокращения
|
"info", "me", "мои",
|
||||||
],
|
],
|
||||||
|
|
||||||
"myid": [
|
"myid": [
|
||||||
"myid", "мойайди", # основные
|
"myid", "мойайди", # основные
|
||||||
"ьншв", "vjqfqlb", # раскладка
|
"ьншв", "vjqfqlb", # раскладка
|
||||||
"mid", "мид", # сокращения
|
"mid", "мид",
|
||||||
],
|
],
|
||||||
|
|
||||||
"chatid": [
|
"chatid": [
|
||||||
"chatid", "айдичата", # основные
|
"chatid", "айдичата", # основные
|
||||||
"срфешв", "fqlbxfnf", # раскладка
|
"срфешв", "fqlbxfnf", # раскладка
|
||||||
"cid", "чатид", # сокращения
|
"cid", "чатид",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== РЕПОРТЫ ====================
|
# ==================== РЕПОРТЫ ====================
|
||||||
|
|
||||||
"report": [
|
"report": [
|
||||||
"report", "репорт", "жалоба", # основные
|
"report", "репорт", "жалоба", # основные
|
||||||
"кузщке", "htgjhn", ";fkj,f", # раскладка
|
"кузщке", "htgjhn", ";fkj,f", # раскладка
|
||||||
"rep", "r", "жал", # сокращения
|
"rep", "r", "жал",
|
||||||
],
|
],
|
||||||
|
|
||||||
"reporthelp": [
|
"reporthelp": [
|
||||||
"reporthelp", "помощьрепорт", # основные
|
"reporthelp", "помощьрепорт", # основные
|
||||||
"кузщкерудз", "gjvjomhtgjhn", # раскладка
|
"кузщкерудз", "gjvjomhtgjhn", # раскладка
|
||||||
"rephelp", "rh", "хелпреп", # сокращения
|
"rephelp", "rh", "хелпреп",
|
||||||
],
|
],
|
||||||
|
|
||||||
"reportstats": [
|
"reportstats": [
|
||||||
"reportstats", "статистикарепортов", # основные
|
"reportstats", "статистикарепортов", # основные
|
||||||
"кузщкеыефnы", "cnfnbcnbrf", # раскладка
|
"кузщкеыефnы", "cnfnbcnbrf", # раскладка
|
||||||
"rstat", "rs", "статреп", # сокращения
|
"rstat", "rs", "статреп",
|
||||||
],
|
],
|
||||||
|
|
||||||
"checkreport": [
|
"checkreport": [
|
||||||
"checkreport", "проверкарепорта", # основные
|
"checkreport", "проверкарепорта", # основные
|
||||||
"сруслкузщке", "ghjdthrf", # раскладка
|
"сруслкузщке", "ghjdthrf", # раскладка
|
||||||
"crep", "cr", "провреп", # сокращения
|
"crep", "cr", "провреп",
|
||||||
],
|
],
|
||||||
|
|
||||||
"closereport": [
|
"closereport": [
|
||||||
"closereport", "закрытьрепорт", # основные
|
"closereport", "закрытьрепорт", # основные
|
||||||
"сдщыукузщке", "pfrhsnm", # раскладка
|
"сдщыукузщке", "pfrhsnm", # раскладка
|
||||||
"close", "cl", "закреп", # сокращения
|
"close", "cl", "закреп",
|
||||||
],
|
],
|
||||||
|
|
||||||
"banreport": [
|
"banreport": [
|
||||||
"banreport", "забанитьрепорт", # основные
|
"banreport", "забанитьрепорт", # основные
|
||||||
"фтшкузщке", "pf,fybnm", # раскладка
|
"фтшкузщке", "pf,fybnm", # раскладка
|
||||||
"banrep", "br", "банреп", # сокращения
|
"banrep", "br", "банреп",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== ЭМОДЗИ ====================
|
# ==================== ЭМОДЗИ ====================
|
||||||
|
|
||||||
"emoji": [
|
"emoji": [
|
||||||
"emoji", "эмодзи", # основные
|
"emoji", "эмодзи", # основные
|
||||||
"уьщош", "'vjlpb", # раскладка
|
"уьщош", "'vjlpb", # раскладка
|
||||||
"em", "emj", "эм", # сокращения
|
"em", "emj", "эм",
|
||||||
],
|
],
|
||||||
|
|
||||||
"emojihelp": [
|
"emojihelp": [
|
||||||
"emojihelp", "помощьэмодзи", # основные
|
"emojihelp", "помощьэмодзи", # основные
|
||||||
"уьщошрудз", "gjvjom'vjlpb", # раскладка
|
"уьщошрудз", "gjvjom'vjlpb", # раскладка
|
||||||
"emhelp", "emh", "хелпэм", # сокращения
|
"emhelp", "emh", "хелпэм",
|
||||||
],
|
],
|
||||||
|
|
||||||
# ==================== СИСТЕМНЫЕ ====================
|
# ==================== СИСТЕМНЫЕ ====================
|
||||||
|
|
||||||
"ping": [
|
"ping": [
|
||||||
"ping", "пинг", # основные
|
"ping", "пинг", # основные
|
||||||
"зштп", "gbyp", # раскладка
|
"зштп", "gbyp", # раскладка
|
||||||
"p", "пн", # сокращения
|
"p", "пн",
|
||||||
],
|
],
|
||||||
|
|
||||||
"version": [
|
"version": [
|
||||||
"version", "версия", # основные
|
"version", "версия", # основные
|
||||||
"дукышщт", "dthcbz", # раскладка
|
"дукышщт", "dthcbz", # раскладка
|
||||||
"ver", "v", "вер", # сокращения
|
"ver", "v",
|
||||||
],
|
],
|
||||||
|
|
||||||
"reload": [
|
"reload": [
|
||||||
"reload", "перезагрузка", # основные
|
"reload", "перезагрузка", # основные
|
||||||
"кудщфв", "gthtpfuheprf", # раскладка
|
"кудщфв", "gthtpfuheprf", # раскладка
|
||||||
"rl", "restart", "рест", # сокращения
|
"rl", "restart", "рест",
|
||||||
],
|
],
|
||||||
|
|
||||||
"logs": [
|
"logs": [
|
||||||
"logs", "логи", # основные
|
"logs", "логи", # основные
|
||||||
"дщпы", "kjub", # раскладка
|
"дщпы", "kjub", # раскладка
|
||||||
"log", "l", "лог", # сокращения
|
"log", "l",
|
||||||
|
],
|
||||||
|
|
||||||
|
"cancel": [
|
||||||
|
"cancel", "c", # основные
|
||||||
|
"отменить", "сфтскд", # раскладка
|
||||||
|
],
|
||||||
|
|
||||||
|
"redactcomment": [
|
||||||
|
"redactcomment", "editcomment", "комментарии", "redc", # основные + сокращения
|
||||||
|
"кувфсщтскщйьщк", "gfhthfyjdfz", # раскладка
|
||||||
|
"redcom", "editcom", "коммент", "rc", # дополнения
|
||||||
],
|
],
|
||||||
"redactcomment": ["redactcomment", "editcomment", "комментарии", "redc"],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class _Settings(BaseSettings):
|
|||||||
# Параметры сообщений
|
# Параметры сообщений
|
||||||
PARSE_MODE: str = "HTML"
|
PARSE_MODE: str = "HTML"
|
||||||
PREFIX: str = "/!.&?"
|
PREFIX: str = "/!.&?"
|
||||||
|
LOG_LEVEL: str = "TRACE"
|
||||||
|
|
||||||
# Разрешения и логирование
|
# Разрешения и логирование
|
||||||
BOT_EDIT: bool = False
|
BOT_EDIT: bool = False
|
||||||
@@ -55,7 +56,6 @@ class _Settings(BaseSettings):
|
|||||||
# Идентификаторы
|
# Идентификаторы
|
||||||
OWNER_ID: list[int] = [6751720805]
|
OWNER_ID: list[int] = [6751720805]
|
||||||
ADMIN_ID: list[int] = []
|
ADMIN_ID: list[int] = []
|
||||||
ADMIN_CHAT_ID: int = 0
|
|
||||||
|
|
||||||
# Настройки бота
|
# Настройки бота
|
||||||
BOT_NAME: str = "Бот"
|
BOT_NAME: str = "Бот"
|
||||||
@@ -89,6 +89,24 @@ class _Settings(BaseSettings):
|
|||||||
description="URL фото по умолчанию"
|
description="URL фото по умолчанию"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ================= АДМИНСКИЕ УВЕДОМЛЕНИЯ =================
|
||||||
|
|
||||||
|
# ID чата для уведомлений о банвордах/спаме
|
||||||
|
ADMIN_CHAT_ID: Optional[int] = -1002522785068
|
||||||
|
|
||||||
|
# ID топика для уведомлений о банвордах (опционально)
|
||||||
|
# Если None - уведомления идут в основной чат (General)
|
||||||
|
ADMIN_THREAD_ID: Optional[int] = None # Например: 12345
|
||||||
|
|
||||||
|
# ================= РЕПОРТЫ =================
|
||||||
|
|
||||||
|
# ID чата для репортов (если None, репорты идут владельцам в ЛС)
|
||||||
|
REPORT_CHAT_ID: Optional[int] = ADMIN_CHAT_ID # Можно тот же чат или другой
|
||||||
|
|
||||||
|
# ID топика для репортов (опционально)
|
||||||
|
# Если None - репорты идут в основной чат (General)
|
||||||
|
REPORT_THREAD_ID: Optional[int] = None # ✅ ИСПРАВЛЕНО: было ADMIN_THREAD_ID
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Права администратора
|
# Права администратора
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
|
|||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
from .database import Database, get_db
|
from .database import Database, get_db
|
||||||
from .repository import BanWordsRepository
|
from .repository import BanWordsRepository
|
||||||
from .models import BanWordType, SpamStat, SpamLog, TempBanWord
|
from .models import BanWordType, SpamStat, SpamLog, TempBanWord, AutoComment
|
||||||
|
|
||||||
from sqlalchemy import select, delete, func, desc
|
from sqlalchemy import select, delete, func, desc
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ class BanWordsManager:
|
|||||||
async def init(self) -> None:
|
async def init(self) -> None:
|
||||||
"""Инициализирует базу данных и загружает кэш"""
|
"""Инициализирует базу данных и загружает кэш"""
|
||||||
await self.db.init()
|
await self.db.init()
|
||||||
|
await self.init_default_bot_settings() # ← добавлено
|
||||||
await self.refresh_cache()
|
await self.refresh_cache()
|
||||||
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
|
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
|
||||||
|
|
||||||
@@ -335,7 +336,6 @@ class BanWordsManager:
|
|||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
if now >= silence_until:
|
if now >= silence_until:
|
||||||
# Время истекло - удаляем настройку
|
|
||||||
await self.disable_silence_mode()
|
await self.disable_silence_mode()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -381,7 +381,6 @@ class BanWordsManager:
|
|||||||
now = datetime.now().timestamp()
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
if now >= conflict_until:
|
if now >= conflict_until:
|
||||||
# Время истекло
|
|
||||||
await self.disable_conflict_mode()
|
await self.disable_conflict_mode()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -433,7 +432,6 @@ class BanWordsManager:
|
|||||||
"""Получает общую статистику"""
|
"""Получает общую статистику"""
|
||||||
db_stats = await self.repo.get_stats()
|
db_stats = await self.repo.get_stats()
|
||||||
|
|
||||||
# Добавляем информацию о кэше
|
|
||||||
cache_info = {
|
cache_info = {
|
||||||
'cache_active': self._cache_banwords is not None,
|
'cache_active': self._cache_banwords is not None,
|
||||||
'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None
|
'cache_updated_at': self._cache_updated_at.isoformat() if self._cache_updated_at else None
|
||||||
@@ -480,7 +478,6 @@ class BanWordsManager:
|
|||||||
"""
|
"""
|
||||||
async with self.db.get_session() as session:
|
async with self.db.get_session() as session:
|
||||||
try:
|
try:
|
||||||
# Группируем по matched_word и считаем количество
|
|
||||||
query = select(
|
query = select(
|
||||||
SpamLog.matched_word,
|
SpamLog.matched_word,
|
||||||
SpamLog.match_type,
|
SpamLog.match_type,
|
||||||
@@ -497,7 +494,6 @@ class BanWordsManager:
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
# Форматируем результат
|
|
||||||
top_words = []
|
top_words = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
top_words.append({
|
top_words.append({
|
||||||
@@ -531,7 +527,6 @@ class BanWordsManager:
|
|||||||
try:
|
try:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Ищем истёкшие временные слова
|
|
||||||
query = select(TempBanWord).where(
|
query = select(TempBanWord).where(
|
||||||
TempBanWord.expires_at < now
|
TempBanWord.expires_at < now
|
||||||
)
|
)
|
||||||
@@ -541,23 +536,18 @@ class BanWordsManager:
|
|||||||
if not expired_words:
|
if not expired_words:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Собираем информацию для логирования
|
|
||||||
expired_info = []
|
expired_info = []
|
||||||
for word in expired_words:
|
for word in expired_words:
|
||||||
expired_info.append({
|
expired_info.append({
|
||||||
'word': word.word,
|
'word': word.word,
|
||||||
'type': word.word_type.value,
|
'type': word.type.value, # ← ИСПРАВЛЕНО: было word.word_type.value
|
||||||
'expires_at': word.expires_at
|
'expires_at': word.expires_at
|
||||||
})
|
})
|
||||||
await session.delete(word)
|
await session.delete(word)
|
||||||
|
|
||||||
# Сохраняем изменения
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Обновляем кеш
|
|
||||||
await self.refresh_cache()
|
await self.refresh_cache()
|
||||||
|
|
||||||
# Логируем подробности
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Удалено {len(expired_words)} истёкших временных банвордов",
|
f"Удалено {len(expired_words)} истёкших временных банвордов",
|
||||||
log_type="DATABASE"
|
log_type="DATABASE"
|
||||||
@@ -608,7 +598,6 @@ class BanWordsManager:
|
|||||||
"""
|
"""
|
||||||
async with self.db.get_session() as session:
|
async with self.db.get_session() as session:
|
||||||
try:
|
try:
|
||||||
# Удаляем все записи
|
|
||||||
await session.execute(delete(SpamLog))
|
await session.execute(delete(SpamLog))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -629,32 +618,30 @@ class BanWordsManager:
|
|||||||
"""
|
"""
|
||||||
Получает настройки автокомментариев для канала.
|
Получает настройки автокомментариев для канала.
|
||||||
|
|
||||||
Args:
|
ВАЖНО: возвращает сохранённые поля даже когда is_enabled=False,
|
||||||
channel_id: ID канала
|
чтобы UI/preview показывали реальную конфигурацию.
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Настройки или значения по умолчанию
|
|
||||||
"""
|
"""
|
||||||
from configs import settings
|
from configs import settings
|
||||||
|
|
||||||
auto_comment = await self.repo.get_auto_comment(channel_id)
|
auto_comment = await self.repo.get_auto_comment(channel_id)
|
||||||
|
|
||||||
if auto_comment and auto_comment.is_enabled:
|
defaults = {
|
||||||
return {
|
|
||||||
'text': auto_comment.text,
|
|
||||||
'button_text': auto_comment.button_text,
|
|
||||||
'button_url': auto_comment.button_url,
|
|
||||||
'photo_url': auto_comment.photo_url,
|
|
||||||
'is_enabled': auto_comment.is_enabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Возвращаем настройки по умолчанию из .env
|
|
||||||
return {
|
|
||||||
'text': settings.AUTO_COMMENT_TEXT,
|
'text': settings.AUTO_COMMENT_TEXT,
|
||||||
'button_text': settings.AUTO_COMMENT_BUTTON_TEXT,
|
'button_text': settings.AUTO_COMMENT_BUTTON_TEXT,
|
||||||
'button_url': settings.AUTO_COMMENT_BUTTON_URL,
|
'button_url': settings.AUTO_COMMENT_BUTTON_URL,
|
||||||
'photo_url': settings.AUTO_COMMENT_PHOTO_URL,
|
'photo_url': settings.AUTO_COMMENT_PHOTO_URL,
|
||||||
'is_enabled': False, # По умолчанию выключено
|
'is_enabled': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not auto_comment:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
return {
|
||||||
|
'text': auto_comment.text if auto_comment.text is not None else defaults['text'],
|
||||||
|
'button_text': auto_comment.button_text if auto_comment.button_text is not None else defaults['button_text'],
|
||||||
|
'button_url': auto_comment.button_url if auto_comment.button_url is not None else defaults['button_url'],
|
||||||
|
'photo_url': auto_comment.photo_url if auto_comment.photo_url is not None else defaults['photo_url'],
|
||||||
|
'is_enabled': bool(auto_comment.is_enabled),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def save_auto_comment_settings(
|
async def save_auto_comment_settings(
|
||||||
@@ -715,6 +702,141 @@ class BanWordsManager:
|
|||||||
channel_id, 'photo_url', photo_url, updated_by
|
channel_id, 'photo_url', photo_url, updated_by
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def log_report(
|
||||||
|
self,
|
||||||
|
report_id: str,
|
||||||
|
reporter_id: int,
|
||||||
|
reporter_username: Optional[str],
|
||||||
|
reported_user_id: int,
|
||||||
|
reported_username: Optional[str],
|
||||||
|
chat_id: int,
|
||||||
|
chat_title: Optional[str],
|
||||||
|
message_id: int,
|
||||||
|
message_thread_id: Optional[int],
|
||||||
|
message_text: Optional[str],
|
||||||
|
reason: str
|
||||||
|
) -> bool:
|
||||||
|
"""Логирует репорт в БД"""
|
||||||
|
return await self.repo.log_report(
|
||||||
|
report_id=report_id,
|
||||||
|
reporter_id=reporter_id,
|
||||||
|
reporter_username=reporter_username,
|
||||||
|
reported_user_id=reported_user_id,
|
||||||
|
reported_username=reported_username,
|
||||||
|
chat_id=chat_id,
|
||||||
|
chat_title=chat_title,
|
||||||
|
message_id=message_id,
|
||||||
|
message_thread_id=message_thread_id,
|
||||||
|
message_text=message_text,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== ✅ КАНАЛЫ АВТОКОММЕНТАРИЕВ ====================
|
||||||
|
|
||||||
|
async def add_auto_comment_channel(self, channel_id: int, added_by: int) -> bool:
|
||||||
|
"""✅ Добавляет новый канал в БД"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
try:
|
||||||
|
new_channel = AutoComment(
|
||||||
|
channel_id=channel_id,
|
||||||
|
text="",
|
||||||
|
button_text="",
|
||||||
|
button_url="",
|
||||||
|
photo_url="",
|
||||||
|
is_enabled=False,
|
||||||
|
updated_by=added_by,
|
||||||
|
)
|
||||||
|
session.add(new_channel)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(new_channel)
|
||||||
|
logger.info(f"✅ Канал добавлен: {channel_id}", log_type="CHANNEL")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка добавления канала {channel_id}: {e}", log_type="CHANNEL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_auto_comment_channels(self) -> List[int]:
|
||||||
|
"""✅ Возвращает все channel_id из БД"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.execute(select(AutoComment.channel_id).distinct())
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def delete_auto_comment(self, channel_id: int) -> bool:
|
||||||
|
"""✅ Удаляет настройки канала"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
delete(AutoComment).where(AutoComment.channel_id == channel_id)
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"✅ Канал удален: {channel_id}", log_type="CHANNEL")
|
||||||
|
return True
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Ошибка удаления канала {channel_id}: {e}", log_type="CHANNEL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ==================== BOT SETTINGS (замена .env) ====================
|
||||||
|
async def get_bot_settings(self) -> dict:
|
||||||
|
"""Получает все настройки бота из БД"""
|
||||||
|
settings = {
|
||||||
|
'admin_chat_id': await self.repo.get_setting("admin_chat_id"),
|
||||||
|
'admin_thread_id': await self.repo.get_setting("admin_thread_id"),
|
||||||
|
'report_chat_id': await self.repo.get_setting("report_chat_id"),
|
||||||
|
'report_thread_id': await self.repo.get_setting("report_thread_id"),
|
||||||
|
}
|
||||||
|
return {k: v for k, v in settings.items() if v is not None}
|
||||||
|
|
||||||
|
async def set_bot_setting(self, key: str, value: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Сохраняет настройку бота в БД
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: admin_chat_id, admin_thread_id, report_chat_id, report_thread_id
|
||||||
|
value: str или None/null
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если сохранено
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return await self.repo.delete_setting(key)
|
||||||
|
else:
|
||||||
|
return await self.repo.set_setting(key, value)
|
||||||
|
|
||||||
|
async def get_bot_setting(self, key: str) -> Optional[str]:
|
||||||
|
"""Получает ОДНУ настройку бота"""
|
||||||
|
settings = await self.get_bot_settings()
|
||||||
|
return settings.get(key)
|
||||||
|
|
||||||
|
async def init_default_bot_settings(self) -> None:
|
||||||
|
"""Инициализирует настройки по умолчанию из .env, только если они ещё не установлены"""
|
||||||
|
try:
|
||||||
|
from configs import settings
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
"admin_chat_id": getattr(settings, 'ADMIN_CHAT_ID', None),
|
||||||
|
"admin_thread_id": str(getattr(settings, 'ADMIN_THREAD_ID', None)) if getattr(settings, 'ADMIN_THREAD_ID', None) else None,
|
||||||
|
"report_chat_id": getattr(settings, 'REPORT_CHAT_ID', None),
|
||||||
|
"report_thread_id": str(getattr(settings, 'REPORT_THREAD_ID', None)) if getattr(settings, 'REPORT_THREAD_ID', None) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in defaults.items():
|
||||||
|
if value is not None: # В .env значение задано
|
||||||
|
existing = await self.get_bot_setting(key)
|
||||||
|
if existing is None:
|
||||||
|
await self.set_bot_setting(key, str(value))
|
||||||
|
logger.debug(f"Установлена настройка {key} из .env", log_type="SETTINGS")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Настройка {key} уже существует ({existing}), пропускаем", log_type="SETTINGS")
|
||||||
|
|
||||||
|
logger.info("✅ Настройки бота инициализированы из .env (существующие сохранены)", log_type="SETTINGS")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось инициализировать настройки из .env: {e}", log_type="SETTINGS")
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр менеджера
|
# Глобальный экземпляр менеджера
|
||||||
_manager_instance: Optional[BanWordsManager] = None
|
_manager_instance: Optional[BanWordsManager] = None
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ __all__ = (
|
|||||||
"SpamStat",
|
"SpamStat",
|
||||||
"SpamLog",
|
"SpamLog",
|
||||||
"AutoComment",
|
"AutoComment",
|
||||||
|
"Report",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -294,3 +295,68 @@ class AutoComment(Base):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>"
|
return f"<AutoComment(channel_id={self.channel_id}, enabled={self.is_enabled})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Report(Base):
|
||||||
|
"""
|
||||||
|
Модель для хранения статистики репортов.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный ID репорта
|
||||||
|
report_id: Строковый ID репорта (timestamp)
|
||||||
|
reporter_id: ID пользователя, который пожаловался
|
||||||
|
reporter_username: Username жалобщика
|
||||||
|
reported_user_id: ID пользователя, на которого пожаловались
|
||||||
|
reported_username: Username нарушителя
|
||||||
|
chat_id: ID чата, где произошло нарушение
|
||||||
|
chat_title: Название чата
|
||||||
|
message_id: ID сообщения-нарушения
|
||||||
|
message_thread_id: ID топика (если есть)
|
||||||
|
message_text: Текст сообщения (до 500 символов)
|
||||||
|
reason: Причина жалобы
|
||||||
|
status: Статус репорта (pending, closed, banned, deleted)
|
||||||
|
processed_by: ID админа, который обработал
|
||||||
|
created_at: Дата создания репорта
|
||||||
|
processed_at: Дата обработки
|
||||||
|
"""
|
||||||
|
__tablename__ = "reports"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
report_id: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# Информация о жалобщике
|
||||||
|
reporter_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
reporter_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Информация о нарушителе
|
||||||
|
reported_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
reported_username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Информация о чате и сообщении
|
||||||
|
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
chat_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
message_thread_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
message_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Причина и статус
|
||||||
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="pending",
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
) # pending, closed, banned, deleted
|
||||||
|
|
||||||
|
# Обработка
|
||||||
|
processed_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Report(id={self.report_id}, reporter={self.reporter_id}, reported={self.reported_user_id})>"
|
||||||
|
|||||||
@@ -157,12 +157,6 @@ class BanWordsRepository:
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
|
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
|
||||||
"""
|
|
||||||
Получает все банворды, сгруппированные по типам.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {BanWordType: Set[str]}
|
|
||||||
"""
|
|
||||||
result = {
|
result = {
|
||||||
BanWordType.SUBSTRING: set(),
|
BanWordType.SUBSTRING: set(),
|
||||||
BanWordType.LEMMA: set(),
|
BanWordType.LEMMA: set(),
|
||||||
@@ -170,19 +164,28 @@ class BanWordsRepository:
|
|||||||
BanWordType.CONFLICT_SUBSTRING: set(),
|
BanWordType.CONFLICT_SUBSTRING: set(),
|
||||||
BanWordType.CONFLICT_LEMMA: set(),
|
BanWordType.CONFLICT_LEMMA: set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self.db.get_session() as session:
|
async with self.db.get_session() as session:
|
||||||
banwords = await session.execute(select(BanWord))
|
banwords = await session.execute(select(BanWord))
|
||||||
|
loaded = 0
|
||||||
for banword in banwords.scalars():
|
for banword in banwords.scalars():
|
||||||
result[banword.type].add(banword.word)
|
try:
|
||||||
|
word_type = (
|
||||||
except Exception as e:
|
banword.type
|
||||||
logger.error(
|
if isinstance(banword.type, BanWordType)
|
||||||
f"Ошибка получения всех банвордов: {e}",
|
else BanWordType(banword.type.casefold())
|
||||||
|
)
|
||||||
|
if word_type in result:
|
||||||
|
result[word_type].add(banword.word)
|
||||||
|
loaded += 1
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
f"Неизвестный тип: '{banword.type}' для '{banword.word}'",
|
||||||
log_type="DATABASE"
|
log_type="DATABASE"
|
||||||
)
|
)
|
||||||
|
logger.info(f"✅ Кэш загружен: {loaded} банвордов", log_type="DATABASE")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ get_all_banwords: {e}", log_type="DATABASE")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]:
|
async def search_banwords(self, query: str, limit: int = 50) -> List[BanWord]:
|
||||||
@@ -344,8 +347,14 @@ class BanWordsRepository:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
for temp_banword in temp_banwords.scalars():
|
for temp_banword in temp_banwords.scalars():
|
||||||
if temp_banword.type in result:
|
word_type = (
|
||||||
result[temp_banword.type].add(temp_banword.word)
|
temp_banword.type
|
||||||
|
if isinstance(temp_banword.type, BanWordType)
|
||||||
|
else BanWordType(temp_banword.type.casefold())
|
||||||
|
)
|
||||||
|
if word_type in result:
|
||||||
|
result[word_type].add(temp_banword.word)
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1046,3 +1055,336 @@ class BanWordsRepository:
|
|||||||
log_type="DATABASE"
|
log_type="DATABASE"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# === REPORTS ===
|
||||||
|
|
||||||
|
async def log_report(
|
||||||
|
self,
|
||||||
|
report_id: str,
|
||||||
|
reporter_id: int,
|
||||||
|
reporter_username: Optional[str],
|
||||||
|
reported_user_id: int,
|
||||||
|
reported_username: Optional[str],
|
||||||
|
chat_id: int,
|
||||||
|
chat_title: Optional[str],
|
||||||
|
message_id: int,
|
||||||
|
message_thread_id: Optional[int],
|
||||||
|
message_text: Optional[str],
|
||||||
|
reason: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Сохраняет репорт в БД.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_id: Уникальный ID репорта
|
||||||
|
reporter_id: ID жалобщика
|
||||||
|
reporter_username: Username жалобщика
|
||||||
|
reported_user_id: ID нарушителя
|
||||||
|
reported_username: Username нарушителя
|
||||||
|
chat_id: ID чата
|
||||||
|
chat_title: Название чата
|
||||||
|
message_id: ID сообщения
|
||||||
|
message_thread_id: ID топика
|
||||||
|
message_text: Текст сообщения
|
||||||
|
reason: Причина жалобы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если успешно
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report # Импорт здесь, чтобы избежать циклических импортов
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
report = Report(
|
||||||
|
report_id=report_id,
|
||||||
|
reporter_id=reporter_id,
|
||||||
|
reporter_username=reporter_username,
|
||||||
|
reported_user_id=reported_user_id,
|
||||||
|
reported_username=reported_username,
|
||||||
|
chat_id=chat_id,
|
||||||
|
chat_title=chat_title,
|
||||||
|
message_id=message_id,
|
||||||
|
message_thread_id=message_thread_id,
|
||||||
|
message_text=message_text[:500] if message_text else None,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Репорт #{report_id} сохранён в БД",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка сохранения репорта: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_report_status(
|
||||||
|
self,
|
||||||
|
report_id: str,
|
||||||
|
status: str,
|
||||||
|
processed_by: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Обновляет статус репорта.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_id: ID репорта
|
||||||
|
status: Новый статус (closed, banned, deleted)
|
||||||
|
processed_by: ID админа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если успешно
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Report).where(Report.report_id == report_id)
|
||||||
|
)
|
||||||
|
report = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
return False
|
||||||
|
|
||||||
|
report.status = status
|
||||||
|
report.processed_by = processed_by
|
||||||
|
report.processed_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Репорт #{report_id} обновлён: статус={status}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка обновления статуса репорта: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_report_stats(self) -> dict:
|
||||||
|
"""
|
||||||
|
Получает общую статистику по репортам.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Статистика
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
# Всего репортов
|
||||||
|
total_reports = await session.execute(
|
||||||
|
select(func.count(Report.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
# По статусам
|
||||||
|
pending_reports = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(Report.status == "pending")
|
||||||
|
)
|
||||||
|
closed_reports = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(Report.status == "closed")
|
||||||
|
)
|
||||||
|
banned_reports = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(Report.status == "banned")
|
||||||
|
)
|
||||||
|
deleted_reports = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(Report.status == "deleted")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total_reports.scalar_one(),
|
||||||
|
'pending': pending_reports.scalar_one(),
|
||||||
|
'closed': closed_reports.scalar_one(),
|
||||||
|
'banned': banned_reports.scalar_one(),
|
||||||
|
'deleted': deleted_reports.scalar_one(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка получения статистики репортов: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_top_reporters(self, limit: int = 10) -> List[tuple[int, str, int]]:
|
||||||
|
"""
|
||||||
|
Получает топ жалобщиков.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Количество записей
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[tuple[int, str, int]]: [(user_id, username, count), ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Report.reporter_id,
|
||||||
|
Report.reporter_username,
|
||||||
|
func.count(Report.id).label('count')
|
||||||
|
)
|
||||||
|
.group_by(Report.reporter_id, Report.reporter_username)
|
||||||
|
.order_by(func.count(Report.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
(row.reporter_id, row.reporter_username or f"id{row.reporter_id}", row.count)
|
||||||
|
for row in result
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка получения топ жалобщиков: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_top_reported_users(self, limit: int = 10) -> List[tuple[int, str, int]]:
|
||||||
|
"""
|
||||||
|
Получает топ нарушителей.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Количество записей
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[tuple[int, str, int]]: [(user_id, username, count), ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Report.reported_user_id,
|
||||||
|
Report.reported_username,
|
||||||
|
func.count(Report.id).label('count')
|
||||||
|
)
|
||||||
|
.group_by(Report.reported_user_id, Report.reported_username)
|
||||||
|
.order_by(func.count(Report.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
(row.reported_user_id, row.reported_username or f"id{row.reported_user_id}", row.count)
|
||||||
|
for row in result
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка получения топ нарушителей: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_recent_reports(self, limit: int = 20) -> List:
|
||||||
|
"""
|
||||||
|
Получает последние репорты.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Количество записей
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Report]: Список репортов
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Report)
|
||||||
|
.order_by(Report.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка получения последних репортов: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_user_report_count(self, user_id: int, as_reporter: bool = True) -> int:
|
||||||
|
"""
|
||||||
|
Получает количество репортов пользователя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
as_reporter: True - как жалобщик, False - как нарушитель
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Количество репортов
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import Report
|
||||||
|
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
if as_reporter:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(
|
||||||
|
Report.reporter_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(Report.id)).where(
|
||||||
|
Report.reported_user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка подсчёта репортов пользователя: {e}",
|
||||||
|
log_type="DATABASE"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(self, key: str) -> Optional[str]:
|
||||||
|
"""Получает значение настройки"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
result = await session.get(Setting, key)
|
||||||
|
return result.value if result else None
|
||||||
|
|
||||||
|
async def set_setting(self, key: str, value: str) -> bool:
|
||||||
|
"""Устанавливает значение настройки"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
try:
|
||||||
|
setting = await session.get(Setting, key)
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
setting.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
setting = Setting(key=key, value=value)
|
||||||
|
session.add(setting)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"set_setting {key} failed: {e}", log_type="DATABASE")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_setting(self, key: str) -> bool:
|
||||||
|
"""Удаляет настройку"""
|
||||||
|
async with self.db.get_session() as session:
|
||||||
|
try:
|
||||||
|
result = await session.execute(delete(Setting).where(Setting.key == key))
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"delete_setting {key} failed: {e}", log_type="DATABASE")
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Кастомный логгер с поддержкой декораторов и прямого вызова
|
Кастомный логгер с поддержством декораторов и прямого вызова
|
||||||
"""
|
"""
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,7 +43,6 @@ class Logger:
|
|||||||
'<cyan>{extra[user]}</cyan> <red>|</red> <level>{message}</level>'
|
'<cyan>{extra[user]}</cyan> <red>|</red> <level>{message}</level>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, system_name: str = 'PRIMO') -> None:
|
def __init__(self, system_name: str = 'PRIMO') -> None:
|
||||||
"""
|
"""
|
||||||
Инициализация логгера.
|
Инициализация логгера.
|
||||||
@@ -58,6 +57,11 @@ class Logger:
|
|||||||
"""
|
"""
|
||||||
Настройка обработчиков Loguru: консоль и файлы.
|
Настройка обработчиков Loguru: консоль и файлы.
|
||||||
|
|
||||||
|
Учитывает переменную LOG_LEVEL из settings.
|
||||||
|
LOG_LEVEL определяет минимальный уровень для консоли и общего файла,
|
||||||
|
а также влияет на то, какие отдельные файлы создаются:
|
||||||
|
создаются только файлы для уровней >= LOG_LEVEL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start: Если True, сразу логирует запуск проекта
|
start: Если True, сразу логирует запуск проекта
|
||||||
"""
|
"""
|
||||||
@@ -67,6 +71,15 @@ class Logger:
|
|||||||
# Полная очистка настроек
|
# Полная очистка настроек
|
||||||
nlogger.remove()
|
nlogger.remove()
|
||||||
|
|
||||||
|
# Определяем уровень логирования из настроек
|
||||||
|
log_level_str = getattr(settings, 'LOG_LEVEL', 'INFO').upper()
|
||||||
|
# Проверка на допустимость
|
||||||
|
try:
|
||||||
|
log_level_no = nlogger.level(log_level_str).no
|
||||||
|
except ValueError:
|
||||||
|
log_level_str = 'INFO'
|
||||||
|
log_level_no = nlogger.level('INFO').no
|
||||||
|
|
||||||
# Создание директории для файловых логов
|
# Создание директории для файловых логов
|
||||||
log_dir: Path = settings.LOG_DIR
|
log_dir: Path = settings.LOG_DIR
|
||||||
if not log_dir.exists():
|
if not log_dir.exists():
|
||||||
@@ -78,35 +91,39 @@ class Logger:
|
|||||||
sink=stderr,
|
sink=stderr,
|
||||||
format=self._log_format,
|
format=self._log_format,
|
||||||
colorize=True,
|
colorize=True,
|
||||||
level='INFO',
|
level=log_level_str,
|
||||||
filter=lambda rec: rec['extra'].get('log_type') != 'TRACE'
|
filter=lambda rec: rec['extra'].get('log_type') != 'TRACE'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Файловые логи
|
# Файловые логи
|
||||||
if settings.LOG_FILE:
|
if settings.LOG_FILE:
|
||||||
# Общий лог со всеми уровнями
|
# Общий лог со всеми уровнями (начиная с LOG_LEVEL)
|
||||||
nlogger.add(
|
nlogger.add(
|
||||||
sink=log_dir / 'bot.log',
|
sink=log_dir / 'bot.log',
|
||||||
rotation=settings.LOG_ROTATION,
|
rotation=settings.LOG_ROTATION,
|
||||||
retention=settings.LOG_RETENTION,
|
retention=settings.LOG_RETENTION,
|
||||||
format=self._log_format,
|
format=self._log_format,
|
||||||
level='DEBUG',
|
level=log_level_str,
|
||||||
enqueue=True,
|
enqueue=True,
|
||||||
backtrace=True,
|
backtrace=True,
|
||||||
diagnose=True,
|
diagnose=True,
|
||||||
encoding='utf-8'
|
encoding='utf-8'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Раздельные логи по уровням
|
# Раздельные логи по уровням – создаём только для уровней >= LOG_LEVEL
|
||||||
log_levels = {
|
# Список интересующих нас уровней (в порядке возрастания)
|
||||||
'INFO': 'info.log',
|
level_configs = [
|
||||||
'WARNING': 'warning.log',
|
('DEBUG', 'debug.log'),
|
||||||
'ERROR': 'error.log',
|
('INFO', 'info.log'),
|
||||||
'CRITICAL': 'critical.log'
|
('SUCCESS', 'success.log'),
|
||||||
}
|
('WARNING', 'warning.log'),
|
||||||
|
('ERROR', 'error.log'),
|
||||||
|
('CRITICAL', 'critical.log')
|
||||||
|
]
|
||||||
|
|
||||||
|
for level_name, filename in level_configs:
|
||||||
for level_name, filename in log_levels.items():
|
level_no = nlogger.level(level_name).no
|
||||||
|
if level_no >= log_level_no:
|
||||||
nlogger.add(
|
nlogger.add(
|
||||||
sink=log_dir / filename,
|
sink=log_dir / filename,
|
||||||
rotation='10 MB',
|
rotation='10 MB',
|
||||||
@@ -128,7 +145,6 @@ class Logger:
|
|||||||
log_type='START'
|
log_type='START'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_user(event: Optional[EventType] = None) -> str:
|
def format_user(event: Optional[EventType] = None) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -322,8 +338,8 @@ class Logger:
|
|||||||
user: Optional[str] = None,
|
user: Optional[str] = None,
|
||||||
message: Optional[EventType] = None
|
message: Optional[EventType] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Логирование успешного выполнения (уровень INFO)"""
|
"""Логирование успешного выполнения (уровень SUCCESS)"""
|
||||||
self.log_entry('INFO', f"✓ {text}", log_type, user, message)
|
self.log_entry('SUCCESS', text, log_type, user, message)
|
||||||
|
|
||||||
# ================= КОНТЕКСТНЫЕ МЕНЕДЖЕРЫ =================
|
# ================= КОНТЕКСТНЫЕ МЕНЕДЖЕРЫ =================
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user