Добавление работы с конфликтными частями и исправление вайтлиста
This commit is contained in:
@@ -23,7 +23,7 @@ class CallbackStartsWith(BaseFilter):
|
|||||||
Проверяет, начинается ли callback_data с указанного префикса.
|
Проверяет, начинается ли callback_data с указанного префикса.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
prefix: Префикс для проверки (строка или список строк)
|
№ prefix: Префикс для проверки (строка или список строк)
|
||||||
ignore_case: Игнорировать регистр
|
ignore_case: Игнорировать регистр
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -172,7 +172,7 @@ class CallbackMatches(BaseFilter):
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
# Паттерн: user_123, user_456 и т.д.
|
# Паттерн: user_123, user_456 и т.д.
|
||||||
@router.callback_query(CallbackMatches(r"^user_(\d+)$"))
|
@router.callback_query(CallbackMatches(r'^user_(\\d+)$'))
|
||||||
async def user_handler(callback: CallbackQuery, matched: dict):
|
async def user_handler(callback: CallbackQuery, matched: dict):
|
||||||
user_id = matched['groups']
|
user_id = matched['groups']
|
||||||
await callback.answer(f"Пользователь {user_id}")
|
await callback.answer(f"Пользователь {user_id}")
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async def add_conflict_word_cmd(message: Message) -> None:
|
|||||||
try:
|
try:
|
||||||
added = await manager.add_banword(
|
added = await manager.add_banword(
|
||||||
word=word,
|
word=word,
|
||||||
word_type=BanWordType.CONFLICT_SUBSTRING,
|
word_type=BanWordType.CONFLICT_WORD,
|
||||||
added_by=message.from_user.id,
|
added_by=message.from_user.id,
|
||||||
reason="Конфликтное слово"
|
reason="Конфликтное слово"
|
||||||
)
|
)
|
||||||
@@ -192,7 +192,7 @@ async def remove_conflict_word_cmd(message: Message) -> None:
|
|||||||
try:
|
try:
|
||||||
removed = await manager.remove_banword(
|
removed = await manager.remove_banword(
|
||||||
word=word,
|
word=word,
|
||||||
word_type=BanWordType.CONFLICT_SUBSTRING
|
word_type=BanWordType.CONFLICT_WORD
|
||||||
)
|
)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
@@ -433,3 +433,113 @@ async def conflict_status_cmd(message: Message) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка получения статуса режима: {e}", log_type="CONFLICT")
|
logger.error(f"Ошибка получения статуса режима: {e}", log_type="CONFLICT")
|
||||||
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка получения статуса</b>", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
Command(*COMMANDS.get("addconflictpart", ["addconflictpart"]),
|
||||||
|
prefix=settings.PREFIX,
|
||||||
|
ignore_case=True),
|
||||||
|
IsAdmin()
|
||||||
|
)
|
||||||
|
@log_action(action_name="ADD_CONFLICT_PART", log_args=True)
|
||||||
|
async def add_conflict_part_cmd(message: Message) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет конфликтную часть.
|
||||||
|
|
||||||
|
Использование: /addconflictpart <комбинация>
|
||||||
|
"""
|
||||||
|
success, result = parse_conflict_args(
|
||||||
|
message.text,
|
||||||
|
"addconflictpart",
|
||||||
|
need_minutes=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await message.answer(result, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
word = result[0].lower().strip()
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
added = await manager.add_banword(
|
||||||
|
word=word,
|
||||||
|
word_type=BanWordType.CONFLICT_PART,
|
||||||
|
added_by=message.from_user.id,
|
||||||
|
reason="Конфликтная часть"
|
||||||
|
)
|
||||||
|
|
||||||
|
if added:
|
||||||
|
text = (
|
||||||
|
f"✅ <b>Конфликтная часть добавлена</b>\n\n"
|
||||||
|
f"🧩 Часть: <code>{word}</code>\n"
|
||||||
|
f"🔍 Тип: поиск без пробелов\n\n"
|
||||||
|
f"⚔️ <i>Работает только в режиме антиконфликта</i>\n"
|
||||||
|
f"Активируйте: <code>/stopconflict [минуты]</code>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = f"⚠️ Конфликтная часть <code>{word}</code> уже существует"
|
||||||
|
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка добавления конфликтной части: {e}",
|
||||||
|
log_type="CONFLICT"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
"❌ <b>Ошибка добавления</b>\n\nПопробуйте позже",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
Command(*COMMANDS.get("remconflictpart", ["remconflictpart"]),
|
||||||
|
prefix=settings.PREFIX,
|
||||||
|
ignore_case=True),
|
||||||
|
IsAdmin()
|
||||||
|
)
|
||||||
|
@log_action(action_name="REMOVE_CONFLICT_PART", log_args=True)
|
||||||
|
async def remove_conflict_part_cmd(message: Message) -> None:
|
||||||
|
"""
|
||||||
|
Удаляет конфликтную часть.
|
||||||
|
|
||||||
|
Использование: /remconflictpart <комбинация>
|
||||||
|
"""
|
||||||
|
success, result = parse_conflict_args(
|
||||||
|
message.text,
|
||||||
|
"remconflictpart",
|
||||||
|
need_minutes=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await message.answer(result, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
word = result[0].lower().strip()
|
||||||
|
manager = get_manager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
removed = await manager.remove_banword(
|
||||||
|
word=word,
|
||||||
|
word_type=BanWordType.CONFLICT_PART
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
text = (
|
||||||
|
f"🗑 <b>Конфликтная часть удалена</b>\n\n"
|
||||||
|
f"🧩 Часть: <code>{word}</code>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = f"⚠️ Конфликтная часть <code>{word}</code> не найдена"
|
||||||
|
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка удаления конфликтной части: {e}",
|
||||||
|
log_type="CONFLICT"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
"❌ <b>Ошибка удаления</b>\n\nПопробуйте позже",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from aiogram.types import Message, CallbackQuery
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
from bot.filters.admin import IsAdmin
|
|
||||||
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
|
||||||
@@ -27,7 +26,7 @@ def get_refresh_kb(page: int = 0):
|
|||||||
return ikb.as_markup()
|
return ikb.as_markup()
|
||||||
|
|
||||||
|
|
||||||
async def format_banwords_list(page: int = 0) -> str:
|
async def format_banwords_list() -> str:
|
||||||
"""
|
"""
|
||||||
Форматирует список всех банвордов с разбивкой по типам.
|
Форматирует список всех банвордов с разбивкой по типам.
|
||||||
|
|
||||||
@@ -46,13 +45,14 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
stats = await manager.get_stats()
|
stats = await manager.get_stats()
|
||||||
|
|
||||||
# Извлекаем данные из словаря
|
# Извлекаем данные из словаря
|
||||||
permanent_words = list(data.get('substring', set()))
|
permanent_words = list(data.get('word', set()))
|
||||||
permanent_lemmas = list(data.get('lemma', set()))
|
permanent_lemmas = list(data.get('lemma', set()))
|
||||||
permanent_parts = list(data.get('part', set()))
|
permanent_parts = list(data.get('part', set()))
|
||||||
temp_words = list(data.get('temp_substring', set()))
|
temp_words = list(data.get('temp_word', set()))
|
||||||
temp_lemmas = list(data.get('temp_lemma', set()))
|
temp_lemmas = list(data.get('temp_lemma', set()))
|
||||||
conflict_words = list(data.get('conflict_substring', set()))
|
conflict_words = list(data.get('conflict_word', set()))
|
||||||
conflict_lemmas = list(data.get('conflict_lemma', set()))
|
conflict_lemmas = list(data.get('conflict_lemma', set()))
|
||||||
|
conflict_parts = list(data.get('conflict_part', set()))
|
||||||
exceptions = list(data.get('whitelist', set()))
|
exceptions = list(data.get('whitelist', set()))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -67,7 +67,7 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
total_count = (
|
total_count = (
|
||||||
len(permanent_words) + len(permanent_lemmas) + len(permanent_parts) +
|
len(permanent_words) + len(permanent_lemmas) + len(permanent_parts) +
|
||||||
len(temp_words) + len(temp_lemmas) +
|
len(temp_words) + len(temp_lemmas) +
|
||||||
len(conflict_words) + len(conflict_lemmas)
|
len(conflict_words) + len(conflict_lemmas) + len(conflict_parts)
|
||||||
)
|
)
|
||||||
|
|
||||||
output += f"📊 <b>Общая статистика:</b>\n"
|
output += f"📊 <b>Общая статистика:</b>\n"
|
||||||
@@ -81,21 +81,21 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
output += "🔴 <b>ПОСТОЯННЫЕ ПРАВИЛА:</b>\n\n"
|
output += "🔴 <b>ПОСТОЯННЫЕ ПРАВИЛА:</b>\n\n"
|
||||||
|
|
||||||
if permanent_words:
|
if permanent_words:
|
||||||
output += f"📝 <b>Подстроки</b> ({len(permanent_words)}):\n"
|
output += f"📝 <b>Слова</b> ({len(permanent_words)}):\n"
|
||||||
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_words)[:20]])
|
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_words)[:20]])
|
||||||
if len(permanent_words) > 20:
|
if len(permanent_words) > 20:
|
||||||
words_str += f" ... <i>(+{len(permanent_words) - 20} ещё)</i>"
|
words_str += f" ... <i>(+{len(permanent_words) - 20} ещё)</i>"
|
||||||
output += f"{words_str}\n\n"
|
output += f"{words_str}\n\n"
|
||||||
|
|
||||||
if permanent_lemmas:
|
if permanent_lemmas:
|
||||||
output += f"🔤 <b>Леммы</b> ({len(permanent_lemmas)}):\n"
|
output += f"🔤 <b>Леммы (морф.формы)</b> ({len(permanent_lemmas)}):\n"
|
||||||
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_lemmas)[:20]])
|
lemmas_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_lemmas)[:20]])
|
||||||
if len(permanent_lemmas) > 20:
|
if len(permanent_lemmas) > 20:
|
||||||
lemmas_str += f" ... <i>(+{len(permanent_lemmas) - 20} ещё)</i>"
|
lemmas_str += f" ... <i>(+{len(permanent_lemmas) - 20} ещё)</i>"
|
||||||
output += f"{lemmas_str}\n\n"
|
output += f"{lemmas_str}\n\n"
|
||||||
|
|
||||||
if permanent_parts:
|
if permanent_parts:
|
||||||
output += f"🧩 <b>Части</b> ({len(permanent_parts)}):\n"
|
output += f"🧩 <b>Части в сообщении</b> ({len(permanent_parts)}):\n"
|
||||||
parts_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_parts)[:20]])
|
parts_str = ', '.join([f"<code>{w}</code>" for w in sorted(permanent_parts)[:20]])
|
||||||
if len(permanent_parts) > 20:
|
if len(permanent_parts) > 20:
|
||||||
parts_str += f" ... <i>(+{len(permanent_parts) - 20} ещё)</i>"
|
parts_str += f" ... <i>(+{len(permanent_parts) - 20} ещё)</i>"
|
||||||
@@ -106,7 +106,7 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
output += "⏱ <b>ВРЕМЕННЫЕ ПРАВИЛА:</b>\n\n"
|
output += "⏱ <b>ВРЕМЕННЫЕ ПРАВИЛА:</b>\n\n"
|
||||||
|
|
||||||
if temp_words:
|
if temp_words:
|
||||||
output += f"📝 <b>Временные подстроки</b> ({len(temp_words)}):\n"
|
output += f"📝 <b>Временные слова</b> ({len(temp_words)}):\n"
|
||||||
# Для временных слов нужна дополнительная информация о времени истечения
|
# Для временных слов нужна дополнительная информация о времени истечения
|
||||||
# Пока просто выводим список
|
# Пока просто выводим список
|
||||||
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_words)[:15]])
|
words_str = ', '.join([f"<code>{w}</code>" for w in sorted(temp_words)[:15]])
|
||||||
@@ -122,7 +122,7 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
output += f"{lemmas_str}\n\n"
|
output += f"{lemmas_str}\n\n"
|
||||||
|
|
||||||
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
|
# === КОНФЛИКТНЫЕ ПРАВИЛА ===
|
||||||
if conflict_words or conflict_lemmas:
|
if conflict_words or conflict_lemmas or conflict_parts:
|
||||||
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
|
output += "⚔️ <b>КОНФЛИКТНЫЕ ПРАВИЛА:</b>\n"
|
||||||
output += "<i>(работают только в режиме <code>/stopconflict</code> <code>время</code>)</i>\n\n"
|
output += "<i>(работают только в режиме <code>/stopconflict</code> <code>время</code>)</i>\n\n"
|
||||||
|
|
||||||
@@ -140,10 +140,17 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
lemmas_str += f" ... <i>(+{len(conflict_lemmas) - 15} ещё)</i>"
|
lemmas_str += f" ... <i>(+{len(conflict_lemmas) - 15} ещё)</i>"
|
||||||
output += f"{lemmas_str}\n\n"
|
output += f"{lemmas_str}\n\n"
|
||||||
|
|
||||||
|
if conflict_parts:
|
||||||
|
output += f"🧩 <b>Конфликтные части</b> ({len(conflict_parts)}):\n"
|
||||||
|
parts_str = ', '.join([f"<code>{w}</code>" for w in sorted(conflict_parts)[:15]])
|
||||||
|
if len(conflict_parts) > 15:
|
||||||
|
parts_str += f" ... <i>(+{len(conflict_parts) - 15} ещё)</i>"
|
||||||
|
output += f"{parts_str}\n\n"
|
||||||
|
|
||||||
# === ИСКЛЮЧЕНИЯ (WHITELIST) ===
|
# === ИСКЛЮЧЕНИЯ (WHITELIST) ===
|
||||||
if exceptions:
|
if exceptions:
|
||||||
output += f"✅ <b>ИСКЛЮЧЕНИЯ</b> ({len(exceptions)}):\n"
|
output += f"✅ <b>ИСКЛЮЧЕНИЯ</b> ({len(exceptions)}):\n"
|
||||||
exc_str = ', '.join([f"<code>{exceptions}</code>" for w in sorted(exceptions)[:15]])
|
exc_str = ', '.join([f"<code>{w}</code>" for w in sorted(exceptions)[:15]])
|
||||||
if len(exceptions) > 15:
|
if len(exceptions) > 15:
|
||||||
exc_str += f" ... <i>(+{len(exceptions) - 15} ещё)</i>"
|
exc_str += f" ... <i>(+{len(exceptions) - 15} ещё)</i>"
|
||||||
output += f"{exc_str}\n\n"
|
output += f"{exc_str}\n\n"
|
||||||
@@ -183,7 +190,7 @@ async def format_banwords_list(page: int = 0) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("listwords:refresh"))
|
@router.callback_query(F.data.startswith("listwords:refresh"))
|
||||||
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True), IsAdmin())
|
@router.message(Command(*COMMANDS[CMD], prefix=settings.PREFIX, ignore_case=True))
|
||||||
@log_action(action_name="LISTWORDS_COMMAND")
|
@log_action(action_name="LISTWORDS_COMMAND")
|
||||||
async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -209,7 +216,7 @@ async def listwords_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# Формируем список
|
# Формируем список
|
||||||
try:
|
try:
|
||||||
text = await format_banwords_list(page)
|
text = await format_banwords_list()
|
||||||
keyboard = get_refresh_kb(page)
|
keyboard = get_refresh_kb(page)
|
||||||
|
|
||||||
if is_callback:
|
if is_callback:
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# Формируем текст помощи
|
# Формируем текст помощи
|
||||||
help_text = (
|
help_text = (
|
||||||
f'{tg_emoji(4961073056677103064)} <b>PrimoGuard - Бот-модератор</b>\n\n'
|
f'{tg_emoji("4961073056677103064")} <b>PrimoGuard - Бот-модератор</b>\n\n'
|
||||||
'<blockquote>Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка подстрок, лемм, временных блокировок и режимов модерации.</blockquote>\n\n'
|
'<blockquote>Автоматическое удаление сообщений с запрещёнными словами.\nПоддержка слов, лемм, временных блокировок и режимов модерации.</blockquote>\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Команды просмотра ===
|
# === Команды просмотра ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4961141003059725568)} <b>Просмотр:</b>\n'
|
f'{tg_emoji("4961141003059725568")} <b>Просмотр:</b>\n'
|
||||||
'<b>/list</b> — список всех правил и слов\n'
|
'<b>/list</b> — список всех правил и слов\n'
|
||||||
'<b>/stats</b> — статистика по удалениям\n'
|
'<b>/stats</b> — статистика по удалениям\n'
|
||||||
'<b>/id</b> — получение айди пользователя\n'
|
'<b>/id</b> — получение айди пользователя\n'
|
||||||
@@ -68,23 +68,23 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# === Постоянные банворды ===
|
# === Постоянные банворды ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4961019408240608234)} <b>Добавить банворд (постоянно):</b>\n'
|
f'{tg_emoji("4961019408240608234")} <b>Добавить банворд (постоянно):</b>\n'
|
||||||
'<code>/addword</code> <code>слово</code> — подстрока (простой поиск)\n'
|
'<code>/word</code> <code>слово</code> — слова (простой поиск)\n'
|
||||||
'<code>/addlemma</code> <code>слово</code> — лемма (все формы слова)\n'
|
'<code>/lemma</code> <code>слово</code> — лемма (все формы слова)\n'
|
||||||
'<code>/addpart</code> <code>комбинация</code> — часть (поиск без пробелов)\n\n'
|
'<code>/part</code> <code>комбинация</code> — часть (поиск без пробелов)\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Временные банворды ===
|
# === Временные банворды ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4960719190026618714)} <b>Добавить банворд (временно):</b>\n'
|
f'{tg_emoji("4960719190026618714")} <b>Добавить банворд (временно):</b>\n'
|
||||||
'<code>/addtempword</code> <code>слово минуты</code> — временная подстрока\n'
|
'<code>/tempword</code> <code>слово минуты</code> — временная слова\n'
|
||||||
'<code>/addtemplemma</code> <code>слово минуты</code> — временная лемма\n'
|
'<code>/templemma</code> <code>слово минуты</code> — временная лемма\n'
|
||||||
'<i>Пример: /addtempword спам 60</i>\n\n'
|
'<i>Пример: /tempword спам 60</i>\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Исключения (whitelist) ===
|
# === Исключения (whitelist) ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4963010134172239128)} <b>Исключения (whitelist):</b>\n'
|
f'{tg_emoji("4963010134172239128")} <b>Исключения (whitelist):</b>\n'
|
||||||
'<code>/addexcept</code> <code>текст</code> — добавить исключение\n'
|
'<code>/addexcept</code> <code>текст</code> — добавить исключение\n'
|
||||||
'<code>/remexcept</code> <code>текст</code> — удалить исключение\n'
|
'<code>/remexcept</code> <code>текст</code> — удалить исключение\n'
|
||||||
'<i>Исключения не проверяются фильтром</i>\n\n'
|
'<i>Исключения не проверяются фильтром</i>\n\n'
|
||||||
@@ -92,36 +92,38 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# === Режимы модерации ===
|
# === Режимы модерации ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4960987543878239236)} <b>Режим тишины:</b>\n'
|
f'{tg_emoji("4960987543878239236")} <b>Режим тишины:</b>\n'
|
||||||
'<code>/silence</code> <code>минуты</code> — удалять ВСЕ сообщения\n'
|
'<code>/silence</code> <code>минуты</code> — удалять ВСЕ сообщения\n'
|
||||||
'<b>/unsilence</b> — отключить режим тишины\n'
|
'<b>/unsilence</b> — отключить режим тишины\n'
|
||||||
'<code>/report</code> — отправить репорт\n\n'
|
'<code>/report</code> — отправить репорт\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4960986152308835400)} <b>Режим антиконфликта:</b>\n'
|
f'{tg_emoji("4960986152308835400")} <b>Режим антиконфликта:</b>\n'
|
||||||
'<code>/addconflictword</code> <code>слово</code> — добавить конфликтное слово\n'
|
'<code>/addconflictword</code> <code>слово</code> — добавить конфликтное слово\n'
|
||||||
'<code>/addconflictlemma</code> <code>слово</code> — добавить конфликтную лемму\n'
|
'<code>/addconflictlemma</code> <code>слово</code> — добавить конфликтную лемму\n'
|
||||||
|
'<code>/addconflictpart</code> <code>слово</code> — добавить конфликтную часть\n'
|
||||||
'<code>/stopconflict</code> <code>минуты</code> — активировать режим\n'
|
'<code>/stopconflict</code> <code>минуты</code> — активировать режим\n'
|
||||||
'<code>/unstopconflict</code> — отключить режим\n\n'
|
'<code>/unstopconflict</code> — отключить режим\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Удаление ===
|
# === Удаление ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4961196485447254983)} <b>Удалить:</b>\n'
|
f'{tg_emoji("4961196485447254983")} <b>Удалить:</b>\n'
|
||||||
'<code>/remword</code> <code>слово</code> — удалить подстроку\n'
|
'<code>/remword</code> <code>слово</code> — удалить слову\n'
|
||||||
'<code>/remlemma</code> <code>слово</code> — удалить лемму\n'
|
'<code>/remlemma</code> <code>слово</code> — удалить лемму\n'
|
||||||
'<code>/rempart</code> <code>комбинация</code> — удалить часть\n'
|
'<code>/rempart</code> <code>комбинация</code> — удалить часть\n'
|
||||||
'<code>/remtempword</code> <code>слово</code> — удалить временную подстроку\n'
|
'<code>/remtempword</code> <code>слово</code> — удалить временную слову\n'
|
||||||
'<code>/remtemplemma</code> <code>слово</code> — удалить временную лемму\n'
|
'<code>/remtemplemma</code> <code>слово</code> — удалить временную лемму\n'
|
||||||
'<code>/remconflictword</code> <code>слово</code> — удалить конфликтное слово\n'
|
'<code>/remconflictword</code> <code>слово</code> — удалить конфликтное слово\n'
|
||||||
|
'<code>/remconflictpart</code> <code>слово</code> — удалить конфликтное часть\n'
|
||||||
'<code>/remconflictlemma</code> <code>слово</code> — удалить конфликтную лемму\n\n'
|
'<code>/remconflictlemma</code> <code>слово</code> — удалить конфликтную лемму\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Управление админами (только для суперадминов) ===
|
# === Управление админами (только для суперадминов) ===
|
||||||
if is_super_admin:
|
if is_super_admin:
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4960891456869893259)} <b>Управление админами (только для владельцев):</b>\n'
|
f'{tg_emoji("4960891456869893259")} <b>Управление админами (только для владельцев):</b>\n'
|
||||||
'<code>/addadmin</code> <i>ID</i> — добавить администратора\n'
|
'<code>/addadmin</code> <i>ID</i> — добавить администратора\n'
|
||||||
'<code>/remadmin</code> <i>ID</i> — удалить администратора\n'
|
'<code>/remadmin</code> <i>ID</i> — удалить администратора\n'
|
||||||
'<b>/redactcomment</b> — изменить комментарий под постом\n'
|
'<b>/redactcomment</b> — изменить комментарий под постом\n'
|
||||||
@@ -130,8 +132,8 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
# === Типы проверок ===
|
# === Типы проверок ===
|
||||||
help_text += (
|
help_text += (
|
||||||
f'{tg_emoji(4961021096162755737)} <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'
|
||||||
@@ -159,4 +161,4 @@ async def start_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
log_type="ERROR"
|
log_type="ERROR"
|
||||||
)
|
)
|
||||||
if is_callback:
|
if is_callback:
|
||||||
await update.answer(f'{tg_emoji(4963277744994518278)} Ошибка отображения справки', show_alert=True)
|
await update.answer(f'{tg_emoji("4963277744994518278")} Ошибка отображения справки', show_alert=True)
|
||||||
|
|||||||
@@ -153,7 +153,13 @@ async def stats_cmd(update: Message | CallbackQuery) -> None:
|
|||||||
|
|
||||||
conflict_words_count = len(data.get('conflict_substring', set()))
|
conflict_words_count = len(data.get('conflict_substring', set()))
|
||||||
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
conflict_lemmas_count = len(data.get('conflict_lemma', set()))
|
||||||
total_conflict = conflict_words_count + conflict_lemmas_count
|
conflict_parts_count = len(data.get('conflict_part', set()))
|
||||||
|
|
||||||
|
total_conflict = (
|
||||||
|
conflict_words_count +
|
||||||
|
conflict_lemmas_count +
|
||||||
|
conflict_parts_count
|
||||||
|
)
|
||||||
|
|
||||||
output += f"⚔️ <b>Режим антиконфликта</b>\n"
|
output += f"⚔️ <b>Режим антиконфликта</b>\n"
|
||||||
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
output += f"├─ ⏱ Осталось: <code>{format_time_remaining(time_left_minutes)}</code>\n"
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async def add_word_cmd(message: Message) -> None:
|
|||||||
try:
|
try:
|
||||||
added = await manager.add_banword(
|
added = await manager.add_banword(
|
||||||
word=word,
|
word=word,
|
||||||
word_type=BanWordType.SUBSTRING,
|
word_type=BanWordType.WORD,
|
||||||
added_by=message.from_user.id,
|
added_by=message.from_user.id,
|
||||||
reason=f"Добавлено через команду"
|
reason=f"Добавлено через команду"
|
||||||
)
|
)
|
||||||
@@ -126,7 +126,7 @@ async def add_word_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления банворда: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ async def add_lemma_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления леммы: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ async def add_part_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления части: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ async def add_temp_word_cmd(message: Message) -> None:
|
|||||||
try:
|
try:
|
||||||
added = await manager.add_temp_banword(
|
added = await manager.add_temp_banword(
|
||||||
word=word,
|
word=word,
|
||||||
word_type=BanWordType.SUBSTRING,
|
word_type=BanWordType.WORD,
|
||||||
minutes=minutes,
|
minutes=minutes,
|
||||||
added_by=message.from_user.id
|
added_by=message.from_user.id
|
||||||
)
|
)
|
||||||
@@ -265,7 +265,7 @@ async def add_temp_word_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления временного банворда: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ async def add_temp_lemma_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления временной леммы: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ async def add_exception_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка добавления исключения: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка добавления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ async def remove_word_cmd(message: Message) -> None:
|
|||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
removed = await manager.remove_banword(word=word, word_type=BanWordType.SUBSTRING)
|
removed = await manager.remove_banword(word=word, word_type=BanWordType.WORD)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
text = format_success_message("удалена", word, "подстрока")
|
text = format_success_message("удалена", word, "подстрока")
|
||||||
@@ -394,7 +394,7 @@ async def remove_word_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления банворда: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@ async def remove_lemma_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления леммы: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ async def remove_part_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления части: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ async def remove_temp_word_cmd(message: Message) -> None:
|
|||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.SUBSTRING)
|
removed = await manager.remove_temp_banword(word=word, word_type=BanWordType.WORD)
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
text = format_success_message("удалена", word, "временная подстрока")
|
text = format_success_message("удалена", word, "временная подстрока")
|
||||||
@@ -479,7 +479,7 @@ async def remove_temp_word_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления временного банворда: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@ async def remove_temp_lemma_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления временной леммы: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@@ -536,5 +536,5 @@ async def remove_exception_cmd(message: Message) -> None:
|
|||||||
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="CMD", exc_info=True)
|
logger.error(f"Ошибка удаления исключения: {e}", log_type="CMD")
|
||||||
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
await message.answer("❌ <b>Ошибка удаления</b>\n\nПопробуйте позже", parse_mode="HTML")
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
|
|
||||||
from .default_msg import router as default_message_router
|
from .default_msg import router as default_message_router
|
||||||
from .ping_test import router as ping_test_message_router
|
|
||||||
|
|
||||||
# Настройка экспорта и роутера
|
# Настройка экспорта и роутера
|
||||||
router: Router = Router(name=__name__)
|
router: Router = Router(name=__name__)
|
||||||
|
|
||||||
# Подготовка роутера команд
|
|
||||||
# router.include_routers(
|
|
||||||
# ping_test_message_router,
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Подключение стандартного роутера
|
# Подключение стандартного роутера
|
||||||
router.include_router(default_message_router)
|
router.include_router(default_message_router)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ def setup_middlewares(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
admin_ids: list[int] = settings.ADMIN_ID+settings.OWNER_ID,
|
admin_ids: list[int] = settings.ADMIN_ID+settings.OWNER_ID,
|
||||||
channel_ids: list[int | str] | None = None,
|
channel_ids: list[int | str] | None = None,
|
||||||
enable_spam_check: bool = False,
|
enable_spam_check: bool = settings.enable_spam_check,
|
||||||
enable_subscription_check: bool = False,
|
enable_subscription_check: bool = settings.enable_subscription_check,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Регистрирует все middleware в диспетчере.
|
Регистрирует все middleware в диспетчере.
|
||||||
@@ -138,5 +138,3 @@ def setup_middlewares(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return instances
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Middleware для проверки сообщений на запрещённые слова (банворды).
|
Middleware для проверки сообщений на запрещённые слова (банворды).
|
||||||
|
|
||||||
✅ ИСПРАВЛЕНО:
|
|
||||||
- Полная нормализация текста с использованием UNICODE_MAP
|
|
||||||
- Удаление повторов символов (леееейн → лейн)
|
|
||||||
- Игнорирование разделителей (л.е.й.н → лейн)
|
|
||||||
- Поддержка всех типов проверок (SUBSTRING, LEMMA, PART, CONFLICT)
|
|
||||||
- Белый список и режимы тишины/конфликта
|
|
||||||
- Нет уведомлений в режиме тишины
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||||
@@ -15,7 +7,7 @@ import re
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
from configs import settings, UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
|
from configs import settings, UNICODE_MAP, LATIN_TO_CYRILLIC, CYRILLIC_NORMALIZE
|
||||||
@@ -23,97 +15,86 @@ from database import get_manager, BanWordType
|
|||||||
from bot.special import extract_words, get_lemma
|
from bot.special import extract_words, get_lemma
|
||||||
from middleware.loggers import logger
|
from middleware.loggers import logger
|
||||||
|
|
||||||
__all__ = ("BanWordsMiddleware",)
|
|
||||||
|
|
||||||
|
__all__ = ("BanWordsMiddleware",)
|
||||||
|
URL_PATTERN = re.compile(
|
||||||
|
r'(https?://\S+|www\.\S+)',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
class TextNormalizer:
|
class TextNormalizer:
|
||||||
"""
|
"""
|
||||||
Класс для многоступенчатой нормализации текста.
|
Класс для многоступенчатой нормализации текста.
|
||||||
Приводит различные юникод-символы к базовым буквам,
|
|
||||||
удаляет повторы, убирает разделители.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Объединяем все словари замен в один
|
FULL_MAP: Dict[str, str] = {}
|
||||||
FULL_MAP = {}
|
|
||||||
FULL_MAP.update(LATIN_TO_CYRILLIC)
|
FULL_MAP.update(LATIN_TO_CYRILLIC)
|
||||||
FULL_MAP.update(CYRILLIC_NORMALIZE)
|
FULL_MAP.update(CYRILLIC_NORMALIZE)
|
||||||
FULL_MAP.update(UNICODE_MAP)
|
FULL_MAP.update(UNICODE_MAP)
|
||||||
|
|
||||||
# Символы-разделители, которые могут быть вставлены между буквами
|
|
||||||
SEPARATORS = re.compile(r'[\s.\-_,;:|]+', re.UNICODE)
|
SEPARATORS = re.compile(r'[\s.\-_,;:|]+', re.UNICODE)
|
||||||
|
|
||||||
# Паттерн для поиска повторяющихся букв (3+ раза)
|
|
||||||
REPEAT_PATTERN = re.compile(r'([а-яёa-z])\1{2,}', re.IGNORECASE)
|
REPEAT_PATTERN = re.compile(r'([а-яёa-z])\1{2,}', re.IGNORECASE)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_characters(cls, text: str) -> str:
|
def normalize_characters(cls, text: str) -> str:
|
||||||
"""
|
result: list[str] = []
|
||||||
Заменяет все символы из FULL_MAP на их базовые эквиваленты.
|
|
||||||
Проходит по строке посимвольно для максимальной замены.
|
|
||||||
"""
|
|
||||||
result = []
|
|
||||||
for ch in text:
|
for ch in text:
|
||||||
# Сначала пробуем заменить по карте
|
result.append(cls.FULL_MAP.get(ch, ch))
|
||||||
if ch in cls.FULL_MAP:
|
|
||||||
result.append(cls.FULL_MAP[ch])
|
|
||||||
else:
|
|
||||||
result.append(ch)
|
|
||||||
# Приводим к нижнему регистру после замен (чтобы избежать потери регистра в карте)
|
|
||||||
return ''.join(result).lower()
|
return ''.join(result).lower()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def remove_separators(cls, text: str) -> str:
|
def remove_separators(cls, text: str) -> str:
|
||||||
"""Удаляет разделители между буквами (пробелы, точки и т.д.)"""
|
|
||||||
return cls.SEPARATORS.sub('', text)
|
return cls.SEPARATORS.sub('', text)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def collapse_repeats(cls, text: str, max_repeat: int = 2) -> str:
|
def collapse_repeats(cls, text: str) -> str:
|
||||||
def repl(m):
|
def repl(match: re.Match[str]) -> str:
|
||||||
ch = m.group(1)
|
return match.group(1)
|
||||||
return ch # вместо ch * 2 — теперь схлопываем до одного символа
|
|
||||||
|
|
||||||
return cls.REPEAT_PATTERN.sub(repl, text)
|
return cls.REPEAT_PATTERN.sub(repl, text)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_full(cls, text: str, remove_sep: bool = True, collapse: bool = True) -> str:
|
def normalize_full(
|
||||||
"""
|
cls,
|
||||||
Полная нормализация:
|
text: str,
|
||||||
1. Unicode нормализация (NFKC) для разложения составных символов
|
remove_sep: bool = True,
|
||||||
2. Замена по карте
|
collapse: bool = True
|
||||||
3. Приведение к нижнему регистру
|
) -> str:
|
||||||
4. Удаление разделителей (опционально)
|
|
||||||
5. Схлопывание повторов (опционально)
|
|
||||||
"""
|
|
||||||
# NFKC разлагает символы типа "ё" в "е" + умляут, но нам лучше оставить как есть,
|
|
||||||
# т.к. у нас есть прямые замены. Однако для совместимости применим.
|
|
||||||
text = unicodedata.normalize('NFKC', text)
|
text = unicodedata.normalize('NFKC', text)
|
||||||
# Замена символов
|
|
||||||
text = cls.normalize_characters(text)
|
text = cls.normalize_characters(text)
|
||||||
# Удаление разделителей
|
|
||||||
if remove_sep:
|
if remove_sep:
|
||||||
text = cls.remove_separators(text)
|
text = cls.remove_separators(text)
|
||||||
# Схлопывание повторов
|
|
||||||
if collapse:
|
if collapse:
|
||||||
text = cls.collapse_repeats(text)
|
text = cls.collapse_repeats(text)
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_for_part(cls, text: str) -> str:
|
def normalize_for_part_token(cls, text: str) -> str:
|
||||||
"""
|
"""
|
||||||
Нормализация для типа PART:
|
Нормализация для PART:
|
||||||
- Полная нормализация
|
- NFKC
|
||||||
- Удаление всех не-буквенных символов (кроме пробелов)
|
- lower()
|
||||||
- Приведение к нижнему регистру
|
- удаление zero-width
|
||||||
|
- схлопывание повторов латиницы
|
||||||
|
- БЕЗ LATIN_TO_CYRILLIC
|
||||||
"""
|
"""
|
||||||
text = cls.normalize_full(text, remove_sep=False, collapse=True)
|
text = unicodedata.normalize('NFKC', text)
|
||||||
# Оставляем только буквы и пробелы
|
text = text.lower()
|
||||||
text = re.sub(r'[^а-яёa-z\s]', '', text, flags=re.IGNORECASE)
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
# удаляем zero-width
|
||||||
return text.lower()
|
text = re.sub(r'[\u200B-\u200D\uFEFF]', '', text)
|
||||||
|
|
||||||
|
# схлопываем повторы букв (3+ → 1)
|
||||||
|
text = re.sub(r'([a-z])\1+', r'\1', text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class BanWordsMiddleware(BaseMiddleware):
|
class BanWordsMiddleware(BaseMiddleware):
|
||||||
def __init__(self):
|
|
||||||
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = get_manager()
|
self.manager = get_manager()
|
||||||
self.normalizer = TextNormalizer()
|
self.normalizer = TextNormalizer()
|
||||||
@@ -124,219 +105,178 @@ class BanWordsMiddleware(BaseMiddleware):
|
|||||||
event: Message,
|
event: Message,
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
# Проверяем наличие текста или подписи
|
|
||||||
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)
|
||||||
|
|
||||||
message_text = event.text or event.caption
|
message_text: str = 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: int = event.from_user.id
|
||||||
user_id = event.from_user.id
|
is_super_admin: bool = user_id in settings.OWNER_ID
|
||||||
is_super_admin = user_id in settings.OWNER_ID
|
is_admin: bool = 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)
|
||||||
return None # Сообщение удалено, дальше не обрабатываем
|
return None
|
||||||
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
@staticmethod
|
||||||
"""
|
def is_allowed_url(url: str, allowed: str) -> bool:
|
||||||
Многоступенчатая проверка текста.
|
url_lower = url.lower()
|
||||||
Возвращает словарь с причиной блокировки или None.
|
allowed_lower = allowed.lower()
|
||||||
"""
|
if allowed_lower.endswith('/'):
|
||||||
# 1. Повторяющиеся символы (например, "леееейн") — блокируем сразу
|
# исключение со слешем: только строгое начало с этим слешем
|
||||||
# repeat_result = self._check_repeated_chars(text)
|
return url_lower.startswith(allowed_lower)
|
||||||
# if repeat_result:
|
else:
|
||||||
# return repeat_result
|
# исключение без слеша: разрешаем точное совпадение или начало с добавлением слеша
|
||||||
|
return url_lower == allowed_lower or url_lower.startswith(allowed_lower + '/')
|
||||||
|
|
||||||
# 2. Получаем кэшированные списки
|
async def _check_message(self, text: str) -> Optional[Dict[str, str]]:
|
||||||
substring_words = self.manager.get_banwords_cached(BanWordType.SUBSTRING)
|
whitelist = {
|
||||||
|
w.lower().strip()
|
||||||
|
for w in self.manager.get_whitelist_cached()
|
||||||
|
}
|
||||||
|
|
||||||
|
# ================= URL CHECK =================
|
||||||
|
urls = URL_PATTERN.findall(text)
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
url_lower = url.lower()
|
||||||
|
|
||||||
|
# если URL начинается с разрешённого исключения — пропускаем
|
||||||
|
if any(self.is_allowed_url(url_lower, allowed) for allowed in whitelist):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# если нет разрешения — проверяем WORD-правила для URL
|
||||||
|
for word in self.manager.get_banwords_cached(BanWordType.WORD):
|
||||||
|
if word in url_lower:
|
||||||
|
return {"word": word, "type": "word"}
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# 2. Убираем URL из текста для word/lemma проверки
|
||||||
|
text_without_urls = URL_PATTERN.sub(' ', text)
|
||||||
|
|
||||||
|
word_words = self.manager.get_banwords_cached(BanWordType.WORD)
|
||||||
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
lemma_words = self.manager.get_banwords_cached(BanWordType.LEMMA)
|
||||||
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
part_words = self.manager.get_banwords_cached(BanWordType.PART)
|
||||||
conflict_substring = self.manager.get_banwords_cached(BanWordType.CONFLICT_SUBSTRING)
|
conflict_word = self.manager.get_banwords_cached(BanWordType.CONFLICT_WORD)
|
||||||
conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
|
conflict_lemma = self.manager.get_banwords_cached(BanWordType.CONFLICT_LEMMA)
|
||||||
|
conflict_part = self.manager.get_banwords_cached(BanWordType.CONFLICT_PART)
|
||||||
|
|
||||||
# 3. Белый список
|
|
||||||
if self.manager.is_whitelisted(text):
|
|
||||||
logger.debug(f"⏭️ Пропуск по белому списку: {text[:30]}", log_type="BANWORDS")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 4. Режим тишины
|
|
||||||
if await self.manager.is_silence_active():
|
if await self.manager.is_silence_active():
|
||||||
return {"word": "[режим тишины]", "type": "silence"}
|
return {"word": "[режим тишины]", "type": "silence"}
|
||||||
|
|
||||||
# 5. Режим конфликта (более мягкие правила)
|
|
||||||
if await self.manager.is_conflict_active():
|
if await self.manager.is_conflict_active():
|
||||||
# Проверка conflict_substring (с нормализацией)
|
normalized_text = self.normalizer.normalize_full(text)
|
||||||
normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
|
|
||||||
for word in conflict_substring:
|
for word in conflict_word:
|
||||||
norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
|
if self.normalizer.normalize_full(word) in normalized_text:
|
||||||
if norm_word in normalized_text:
|
return {"word": word, "type": "conflict_word"}
|
||||||
return {"word": word, "type": "conflict_substring"}
|
|
||||||
|
|
||||||
# conflict_lemma
|
|
||||||
for word_text in extract_words(text):
|
for word_text in extract_words(text):
|
||||||
lemma = get_lemma(word_text)
|
if get_lemma(word_text) in conflict_lemma:
|
||||||
if lemma in conflict_lemma:
|
return {"word": word_text, "type": "conflict_lemma"}
|
||||||
return {"word": lemma, "type": "conflict_lemma"}
|
|
||||||
|
|
||||||
# Если в конфликтном режиме ничего не найдено — пропускаем
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 6. Обычный режим: проверка substring (с удалением разделителей и схлопыванием повторов)
|
# WORD — строгое совпадение как отдельное слово
|
||||||
normalized_text = self.normalizer.normalize_full(text, remove_sep=True, collapse=True)
|
for word in word_words:
|
||||||
for word in substring_words:
|
pattern = r'(?<!\w){}(?!\w)'.format(re.escape(word))
|
||||||
norm_word = self.normalizer.normalize_full(word, remove_sep=True, collapse=True)
|
|
||||||
if norm_word in normalized_text:
|
for match in re.finditer(pattern, text_without_urls, re.IGNORECASE):
|
||||||
logger.info(f"✅ SUBSTRING: '{word}'", log_type="BANWORDS")
|
matched = match.group(0).lower()
|
||||||
return {"word": word, "type": "substring"}
|
|
||||||
|
# если совпавшее слово в whitelist — игнорируем
|
||||||
|
if matched in whitelist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# если это начало URL — пропускаем
|
||||||
|
if text[match.end():match.end() + 3] == '://':
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"word": word, "type": "word"}
|
||||||
|
|
||||||
|
# PART
|
||||||
|
usernames = re.findall(r'@[\w_]+', text_without_urls)
|
||||||
|
latin_tokens = re.findall(r'\b[a-zA-Z0-9_]*[a-zA-Z]+[a-zA-Z0-9_]*\b', text_without_urls)
|
||||||
|
|
||||||
|
tokens_to_check = usernames + latin_tokens
|
||||||
|
|
||||||
|
# PART
|
||||||
|
for token in tokens_to_check:
|
||||||
|
token_lower = token.lower()
|
||||||
|
|
||||||
|
# если именно этот токен разрешён
|
||||||
|
normalized_for_whitelist = token_lower.lstrip('@')
|
||||||
|
|
||||||
|
if (
|
||||||
|
token_lower in whitelist or
|
||||||
|
normalized_for_whitelist in whitelist or
|
||||||
|
f"@{normalized_for_whitelist}" in whitelist
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_token = self.normalizer.normalize_for_part_token(token)
|
||||||
|
|
||||||
# 7. Проверка part (строгая нормализация, только буквы и пробелы)
|
|
||||||
part_normalized = self.normalizer.normalize_for_part(text)
|
|
||||||
for part in part_words:
|
for part in part_words:
|
||||||
norm_part = self.normalizer.normalize_for_part(part)
|
norm_part = self.normalizer.normalize_for_part_token(part)
|
||||||
if norm_part in part_normalized:
|
|
||||||
logger.info(f"✅ PART: '{part}'", log_type="BANWORDS")
|
if norm_part in normalized_token:
|
||||||
return {"word": part, "type": "part"}
|
return {"word": part, "type": "part"}
|
||||||
|
|
||||||
# 8. Проверка lemma
|
# CONFLICT PART
|
||||||
for word_text in extract_words(text):
|
for token in tokens_to_check:
|
||||||
# Для леммы тоже применяем нормализацию (удаляем разделители, схлопываем повторы)
|
token_lower = token.lower()
|
||||||
normalized_word = self.normalizer.normalize_full(word_text, remove_sep=True, collapse=True)
|
|
||||||
|
normalized_for_whitelist = token_lower.lstrip('@')
|
||||||
|
|
||||||
|
if (
|
||||||
|
token_lower in whitelist or
|
||||||
|
normalized_for_whitelist in whitelist or
|
||||||
|
f"@{normalized_for_whitelist}" in whitelist
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_token = self.normalizer.normalize_for_part_token(token)
|
||||||
|
|
||||||
|
for part in conflict_part:
|
||||||
|
norm_part = self.normalizer.normalize_for_part_token(part)
|
||||||
|
|
||||||
|
if norm_part in normalized_token:
|
||||||
|
return {"word": part, "type": "conflict_part"}
|
||||||
|
|
||||||
|
# LEMMA
|
||||||
|
for word_text in extract_words(text_without_urls):
|
||||||
|
word_lower = word_text.lower()
|
||||||
|
|
||||||
|
# если слово разрешено — пропускаем
|
||||||
|
if word_lower in whitelist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_word = self.normalizer.normalize_full(word_text)
|
||||||
lemma = get_lemma(normalized_word)
|
lemma = get_lemma(normalized_word)
|
||||||
|
|
||||||
if lemma in lemma_words:
|
if lemma in lemma_words:
|
||||||
logger.info(f"✅ LEMMA: '{lemma}' из '{word_text}'", log_type="BANWORDS")
|
|
||||||
return {"word": lemma, "type": "lemma"}
|
return {"word": lemma, "type": "lemma"}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _check_repeated_chars(self, text: str) -> Optional[Dict[str, str]]:
|
@staticmethod
|
||||||
"""
|
async def _handle_spam(
|
||||||
Проверяет на наличие 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
|
|
||||||
matched_word = spam_result["word"]
|
|
||||||
match_type = spam_result["type"]
|
|
||||||
message_text = message.text or message.caption or "[нет текста]"
|
|
||||||
|
|
||||||
# В режиме тишины удаляем молча
|
|
||||||
if match_type == "silence":
|
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
logger.info(f"🔇 SILENCE: @{user.username or user.id} удалено молча", log_type="BANWORDS")
|
|
||||||
except TelegramBadRequest as e:
|
|
||||||
logger.error(f"❌ Не удалено (silence): {e}", log_type="BANWORDS")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Удаляем сообщение
|
|
||||||
try:
|
|
||||||
await message.delete()
|
|
||||||
logger.info(f"🚫 @{user.username or user.id}: '{matched_word}' ({match_type})", log_type="BANWORDS")
|
|
||||||
except TelegramBadRequest as e:
|
|
||||||
logger.error(f"❌ Не удалено: {e}", log_type="BANWORDS")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Логируем в БД
|
|
||||||
await self.manager.log_spam(
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username or f"id{user.id}",
|
|
||||||
chat_id=message.chat.id,
|
|
||||||
message_text=message_text,
|
|
||||||
matched_word=matched_word,
|
|
||||||
match_type=match_type
|
|
||||||
)
|
|
||||||
|
|
||||||
# Уведомляем админов
|
|
||||||
await self._notify_admins(message, matched_word, match_type, message_text)
|
|
||||||
|
|
||||||
async def _notify_admins(
|
|
||||||
self,
|
|
||||||
message: Message,
|
message: Message,
|
||||||
matched_word: str,
|
|
||||||
match_type: str,
|
|
||||||
message_text: str
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Отправляет уведомление об удалении в админ-чат (берёт ID из БД)"""
|
|
||||||
user = message.from_user
|
|
||||||
username = f"@{user.username}" if user.username else f"ID: {user.id}"
|
|
||||||
spam_count = await self.manager.get_user_spam_count(user.id)
|
|
||||||
chat_title = message.chat.title or "Без названия"
|
|
||||||
source_thread_id = message.message_thread_id
|
|
||||||
|
|
||||||
notification_text = (
|
|
||||||
f"🚫 <b>Удалено сообщение</b>\n\n"
|
|
||||||
f"👤 <b>Пользователь:</b> {username}\n"
|
|
||||||
f"🆔 <b>ID:</b> <code>{user.id}</code>\n"
|
|
||||||
f"📊 <b>Нарушений:</b> {spam_count}\n\n"
|
|
||||||
f"💬 <b>Чат:</b> {self._escape_html(chat_title)}\n"
|
|
||||||
f"🆔 <b>Chat ID:</b> <code>{message.chat.id}</code>\n"
|
|
||||||
f"{'📌 <b>Topic ID:</b> <code>' + str(source_thread_id) + '</code>\n' if source_thread_id else ''}"
|
|
||||||
f"🔗 <b>Message ID:</b> <code>{message.message_id}</code>\n\n"
|
|
||||||
f"🔍 <b>Триггер:</b> <code>{self._escape_html(matched_word)}</code>\n"
|
|
||||||
f"📝 <b>Тип:</b> {self._get_type_emoji(match_type)} {self._escape_html(match_type)}\n\n"
|
|
||||||
f"💬 <b>Текст:</b>\n<code>{self._escape_html(message_text[:500])}</code>"
|
|
||||||
)
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="🔨 Забанить", callback_data=f"spam_ban:{user.id}:{message.chat.id}"),
|
|
||||||
InlineKeyboardButton(text="✅ Закрыть", callback_data="spam_close")
|
|
||||||
],
|
|
||||||
[InlineKeyboardButton(text="📊 Статистика", callback_data=f"spam_stats:{user.id}")]
|
|
||||||
])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ✅ Получаем настройки из БД (динамические, установленные через /settings)
|
await message.delete()
|
||||||
admin_chat_id = await self.manager.get_bot_setting("admin_chat_id")
|
logger.info(f"Удалено сообщение: {message.text}")
|
||||||
admin_thread_id = await self.manager.get_bot_setting("admin_thread_id")
|
except TelegramBadRequest:
|
||||||
|
return
|
||||||
if admin_chat_id:
|
|
||||||
await message.bot.send_message(
|
|
||||||
chat_id=int(admin_chat_id),
|
|
||||||
text=notification_text,
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="HTML",
|
|
||||||
message_thread_id=int(admin_thread_id) if admin_thread_id else None
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Уведомление админам: {e}", log_type="BANWORDS")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_type_emoji(match_type: str) -> str:
|
|
||||||
return {
|
|
||||||
"substring": "🔤",
|
|
||||||
"lemma": "📖",
|
|
||||||
"part": "🧩",
|
|
||||||
"silence": "🔇",
|
|
||||||
"conflict_substring": "⚔️",
|
|
||||||
"conflict_lemma": "⚔️",
|
|
||||||
"repeated_chars": "🔁"
|
|
||||||
}.get(match_type, "❓")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _escape_html(text: str) -> str:
|
|
||||||
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class UserSpamStats:
|
|||||||
self.total_blocks += 1
|
self.total_blocks += 1
|
||||||
self.reputation = max(0.5, self.reputation - 0.3)
|
self.reputation = max(0.5, self.reputation - 0.3)
|
||||||
|
|
||||||
def detect_spam_patterns(self, time_window: float = 10.0) -> Dict[str, Any]:
|
def detect_spam_patterns(self, time_window: float = 2.0) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Умная детекция спама на основе паттернов.
|
Умная детекция спама на основе паттернов.
|
||||||
УЛУЧШЕНО: учитывает скорость отправки сообщений.
|
УЛУЧШЕНО: учитывает скорость отправки сообщений.
|
||||||
@@ -120,7 +120,7 @@ class UserSpamStats:
|
|||||||
current_time = time()
|
current_time = time()
|
||||||
|
|
||||||
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
# 1. КРИТИЧНО: Экстремально быстрая отправка (флуд-бот)
|
||||||
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) < time_window]
|
||||||
if len(very_recent) >= 5:
|
if len(very_recent) >= 5:
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
@@ -133,7 +133,7 @@ class UserSpamStats:
|
|||||||
|
|
||||||
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
# 2. КРИТИЧНО: 8+ сообщений за 5 секунд => агрессивный флуд
|
||||||
recent_5s = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 5.0]
|
recent_5s = [ctx for ctx in recent_contexts if (current_time - ctx.timestamp) < 5.0]
|
||||||
if len(recent_5s) >= 8:
|
if len(recent_5s) >= 15:
|
||||||
return {
|
return {
|
||||||
'is_spam': True,
|
'is_spam': True,
|
||||||
'reason': 'aggressive_flood',
|
'reason': 'aggressive_flood',
|
||||||
@@ -145,7 +145,7 @@ class UserSpamStats:
|
|||||||
|
|
||||||
# 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) >= 15:
|
||||||
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 {
|
||||||
@@ -303,7 +303,8 @@ class AntiSpamMiddleware(BaseMiddleware):
|
|||||||
self.enable_reputation = enable_reputation
|
self.enable_reputation = enable_reputation
|
||||||
self.log_all = log_all
|
self.log_all = log_all
|
||||||
|
|
||||||
def _extract_context(self, event: TelegramObject) -> MessageContext:
|
@staticmethod
|
||||||
|
def _extract_context(event: TelegramObject) -> MessageContext:
|
||||||
"""Извлекает контекст из события"""
|
"""Извлекает контекст из события"""
|
||||||
context = MessageContext()
|
context = MessageContext()
|
||||||
|
|
||||||
|
|||||||
@@ -246,8 +246,8 @@ async def msg(
|
|||||||
keyboard = markups(markup)
|
keyboard = markups(markup)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Попытка редактирования (для callback)
|
|
||||||
if edit_if_possible and isinstance(update, CallbackQuery):
|
if edit_if_possible and isinstance(update, CallbackQuery):
|
||||||
|
try:
|
||||||
sent_message = await message.edit_text(
|
sent_message = await message.edit_text(
|
||||||
text=text,
|
text=text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
@@ -260,8 +260,25 @@ async def msg(
|
|||||||
f"Сообщение отредактировано: {message.message_id}",
|
f"Сообщение отредактировано: {message.message_id}",
|
||||||
log_type='MESSAGE'
|
log_type='MESSAGE'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except (TelegramBadRequest, TelegramForbiddenError):
|
||||||
|
sent_message = await message.answer(
|
||||||
|
text=text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
disable_web_page_preview=disable_web_page_preview,
|
||||||
|
disable_notification=disable_notification,
|
||||||
|
protect_content=protect_content
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise TelegramBadRequest
|
sent_message = await message.answer(
|
||||||
|
text=text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
disable_web_page_preview=disable_web_page_preview,
|
||||||
|
disable_notification=disable_notification,
|
||||||
|
protect_content=protect_content
|
||||||
|
)
|
||||||
|
|
||||||
except (TelegramBadRequest, TelegramForbiddenError):
|
except (TelegramBadRequest, TelegramForbiddenError):
|
||||||
# Отправка нового сообщения
|
# Отправка нового сообщения
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class _Settings(BaseSettings):
|
|||||||
WEBHOOK_URL: Optional[str] = None
|
WEBHOOK_URL: Optional[str] = None
|
||||||
WEBAPP_HOST: str = "0.0.0.0"
|
WEBAPP_HOST: str = "0.0.0.0"
|
||||||
WEBAPP_PORT: int = 3131
|
WEBAPP_PORT: int = 3131
|
||||||
LOG_LEVEL: str = "warning"
|
|
||||||
ACCES_LOG: bool = False
|
ACCES_LOG: bool = False
|
||||||
|
|
||||||
# API ключи
|
# API ключи
|
||||||
@@ -137,7 +136,10 @@ class _Settings(BaseSettings):
|
|||||||
SHOW_CAPTION_ABOVE_MEDIA: bool = False
|
SHOW_CAPTION_ABOVE_MEDIA: bool = False
|
||||||
|
|
||||||
# улучшения
|
# улучшения
|
||||||
ANTI_SPAM: bool = True
|
ANTI_SPAM: bool = False
|
||||||
|
enable_spam_check: bool = False
|
||||||
|
enable_subscription_check: bool = False
|
||||||
|
BOT_USERNAME: str = "@OvhdLayla2_bot"
|
||||||
|
|
||||||
# ================= ВАЛИДАТОРЫ =================
|
# ================= ВАЛИДАТОРЫ =================
|
||||||
@field_validator('PARSE_MODE')
|
@field_validator('PARSE_MODE')
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class BanWordsManager:
|
|||||||
"""Инициализирует базу данных и загружает кэш"""
|
"""Инициализирует базу данных и загружает кэш"""
|
||||||
await self.db.init()
|
await self.db.init()
|
||||||
await self.init_default_bot_settings() # ← добавлено
|
await self.init_default_bot_settings() # ← добавлено
|
||||||
|
await self.init_default_words()
|
||||||
await self.refresh_cache()
|
await self.refresh_cache()
|
||||||
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
|
logger.info("BanWordsManager инициализирован", log_type="DATABASE")
|
||||||
|
|
||||||
@@ -452,12 +453,13 @@ class BanWordsManager:
|
|||||||
admins = await self.repo.get_admins()
|
admins = await self.repo.get_admins()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'substring': banwords.get(BanWordType.SUBSTRING, set()),
|
'word': banwords.get(BanWordType.WORD, set()),
|
||||||
'lemma': banwords.get(BanWordType.LEMMA, set()),
|
'lemma': banwords.get(BanWordType.LEMMA, set()),
|
||||||
'part': banwords.get(BanWordType.PART, set()),
|
'part': banwords.get(BanWordType.PART, set()),
|
||||||
'conflict_substring': banwords.get(BanWordType.CONFLICT_SUBSTRING, set()),
|
'conflict_word': banwords.get(BanWordType.CONFLICT_WORD, set()),
|
||||||
'conflict_lemma': banwords.get(BanWordType.CONFLICT_LEMMA, set()),
|
'conflict_lemma': banwords.get(BanWordType.CONFLICT_LEMMA, set()),
|
||||||
'temp_substring': temp_banwords.get(BanWordType.SUBSTRING, set()),
|
'conflict_part': banwords.get(BanWordType.CONFLICT_PART, set()),
|
||||||
|
'temp_word': temp_banwords.get(BanWordType.WORD, set()),
|
||||||
'temp_lemma': temp_banwords.get(BanWordType.LEMMA, set()),
|
'temp_lemma': temp_banwords.get(BanWordType.LEMMA, set()),
|
||||||
'whitelist': whitelist,
|
'whitelist': whitelist,
|
||||||
'admins': admins
|
'admins': admins
|
||||||
@@ -516,6 +518,50 @@ class BanWordsManager:
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def init_default_words(self) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет базовые банворды и whitelist при первом запуске.
|
||||||
|
Ничего не перезаписывает, если уже существует.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from configs import settings
|
||||||
|
|
||||||
|
# --- Базовые слова ---
|
||||||
|
default_word = {"http", "t.me/"}
|
||||||
|
default_part = {"bot"}
|
||||||
|
default_lemma = {"скам", "мошенник"}
|
||||||
|
|
||||||
|
# Проверяем уже существующие
|
||||||
|
existing = await self.repo.get_all_banwords()
|
||||||
|
|
||||||
|
# word
|
||||||
|
for word in default_word:
|
||||||
|
if word not in existing.get(BanWordType.WORD, set()):
|
||||||
|
await self.repo.add_banword(word, BanWordType.WORD)
|
||||||
|
|
||||||
|
# PART
|
||||||
|
for word in default_part:
|
||||||
|
if word not in existing.get(BanWordType.PART, set()):
|
||||||
|
await self.repo.add_banword(word, BanWordType.PART)
|
||||||
|
|
||||||
|
# LEMMA
|
||||||
|
for word in default_lemma:
|
||||||
|
if word not in existing.get(BanWordType.LEMMA, set()):
|
||||||
|
await self.repo.add_banword(word, BanWordType.LEMMA)
|
||||||
|
|
||||||
|
# --- Добавляем username бота в whitelist ---
|
||||||
|
bot_username = settings.BOT_USERNAME
|
||||||
|
|
||||||
|
if bot_username:
|
||||||
|
whitelist = await self.repo.get_whitelist()
|
||||||
|
if bot_username.lower() not in whitelist:
|
||||||
|
await self.repo.add_whitelist(bot_username.lower())
|
||||||
|
|
||||||
|
logger.info("Базовые слова и whitelist инициализированы", log_type="DATABASE")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка инициализации базовых слов: {e}", log_type="DATABASE")
|
||||||
|
|
||||||
async def cleanup_expired_temp_words(self) -> int:
|
async def cleanup_expired_temp_words(self) -> int:
|
||||||
"""
|
"""
|
||||||
Удаляет истёкшие временные банворды.
|
Удаляет истёкшие временные банворды.
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
class BanWordType(str, PyEnum):
|
class BanWordType(str, PyEnum):
|
||||||
"""Типы банвордов"""
|
"""Типы банвордов"""
|
||||||
SUBSTRING = "substring"
|
WORD = "word"
|
||||||
LEMMA = "lemma"
|
LEMMA = "lemma"
|
||||||
PART = "part"
|
PART = "part"
|
||||||
CONFLICT_SUBSTRING = "conflict_substring"
|
CONFLICT_WORD = "conflict_word"
|
||||||
CONFLICT_LEMMA = "conflict_lemma"
|
CONFLICT_LEMMA = "conflict_lemma"
|
||||||
|
CONFLICT_PART = "conflict_part"
|
||||||
|
|
||||||
|
|
||||||
class SpamMode(str, PyEnum):
|
class SpamMode(str, PyEnum):
|
||||||
@@ -62,7 +63,11 @@ class BanWord(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
type: Mapped[BanWordType] = mapped_column(
|
type: Mapped[BanWordType] = mapped_column(
|
||||||
Enum(BanWordType, native_enum=False),
|
Enum(
|
||||||
|
BanWordType,
|
||||||
|
native_enum=False,
|
||||||
|
values_callable=lambda enum: [e.value for e in enum]
|
||||||
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
@@ -95,8 +100,13 @@ class TempBanWord(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
type: Mapped[BanWordType] = mapped_column(
|
type: Mapped[BanWordType] = mapped_column(
|
||||||
Enum(BanWordType, native_enum=False),
|
Enum(
|
||||||
nullable=False
|
BanWordType,
|
||||||
|
native_enum=False,
|
||||||
|
values_callable=lambda enum: [e.value for e in enum]
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True
|
||||||
)
|
)
|
||||||
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
added_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
added_at: Mapped[datetime] = mapped_column(
|
added_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -158,11 +158,12 @@ class BanWordsRepository:
|
|||||||
|
|
||||||
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
|
async def get_all_banwords(self) -> dict[BanWordType, Set[str]]:
|
||||||
result = {
|
result = {
|
||||||
BanWordType.SUBSTRING: set(),
|
BanWordType.WORD: set(),
|
||||||
BanWordType.LEMMA: set(),
|
BanWordType.LEMMA: set(),
|
||||||
BanWordType.PART: set(),
|
BanWordType.PART: set(),
|
||||||
BanWordType.CONFLICT_SUBSTRING: set(),
|
BanWordType.CONFLICT_WORD: set(),
|
||||||
BanWordType.CONFLICT_LEMMA: set(),
|
BanWordType.CONFLICT_LEMMA: set(),
|
||||||
|
BanWordType.CONFLICT_PART: set(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
async with self.db.get_session() as session:
|
async with self.db.get_session() as session:
|
||||||
@@ -335,7 +336,7 @@ class BanWordsRepository:
|
|||||||
async def get_all_temp_banwords(self) -> dict[BanWordType, Set[str]]:
|
async def get_all_temp_banwords(self) -> dict[BanWordType, Set[str]]:
|
||||||
"""Получает все активные временные банворды по типам"""
|
"""Получает все активные временные банворды по типам"""
|
||||||
result = {
|
result = {
|
||||||
BanWordType.SUBSTRING: set(),
|
BanWordType.WORD: set(),
|
||||||
BanWordType.LEMMA: set(),
|
BanWordType.LEMMA: set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user