Добавление работы с конфликтными частями и исправление вайтлиста

This commit is contained in:
2026-02-25 17:50:11 +07:00
parent 6a4e56c367
commit 54125b82ac
15 changed files with 463 additions and 329 deletions

View File

@@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

View File

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

View File

@@ -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):
# Отправка нового сообщения # Отправка нового сообщения

View File

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

View File

@@ -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:
""" """
Удаляет истёкшие временные банворды. Удаляет истёкшие временные банворды.

View File

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

View File

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