diff --git a/src/action_reporter.py b/src/action_reporter.py index eceeeb6..9090248 100644 --- a/src/action_reporter.py +++ b/src/action_reporter.py @@ -59,23 +59,27 @@ class ActionReporter: return text # Отправляет лог действия в админ-чат - async def log_action(self, action: str, user_id: int, admin_id: Optional[int], reason: str, duration: str, photo_path: Optional[str] = None): + async def log_action(self, action: str, user_id: Optional[int], admin_id: Optional[int], reason: str, duration: str, photo_path: Optional[str] = None): try: # Получаем информацию о пользователе и администраторе - user_info = await self._get_user_info(user_id) admin_info = await self._get_admin_info(admin_id) - + # Формируем сообщение msg = f"⚙️ Действие: {action}\n" - + if duration: msg += f"⏱ Длительность: {duration}\n" - + if reason: msg += f"📝 Причина: {reason}\n" - - msg += f"\n{user_info}\n\n{admin_info}" + + # Добавляем информацию о пользователе, если указан + if user_id is not None: + user_info = await self._get_user_info(user_id) + msg += f"\n{user_info}\n\n{admin_info}" + else: + msg += f"\n{admin_info}" # Отправляем лог с изображением if photo_path and os.path.exists(photo_path): diff --git a/src/bad_words.py b/src/bad_words.py index f1a95be..ea70286 100644 --- a/src/bad_words.py +++ b/src/bad_words.py @@ -15,6 +15,7 @@ BAD_WORDS_FILE = os.path.join(os.path.dirname(__file__), 'data', 'bad_words.json _bad_words_cache = None _exceptions_cache = None _whole_word_patterns_cache = None +_word_start_patterns_cache = None _contains_patterns_cache = None def load_bad_words(): @@ -25,7 +26,7 @@ def load_bad_words(): Returns: tuple: (список бранных слов, список исключений) """ - global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _contains_patterns_cache + global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache try: with open(BAD_WORDS_FILE, 'r', encoding='utf-8') as f: @@ -36,14 +37,16 @@ def load_bad_words(): # Новый формат с паттернами patterns = data['patterns'] _whole_word_patterns_cache = patterns.get('whole_word', []) + _word_start_patterns_cache = patterns.get('word_start', []) _contains_patterns_cache = patterns.get('contains', []) _exceptions_cache = data.get('exceptions', []) # Для обратной совместимости объединяем в один список - _bad_words_cache = _whole_word_patterns_cache + _contains_patterns_cache + _bad_words_cache = _whole_word_patterns_cache + _word_start_patterns_cache + _contains_patterns_cache logger.info( f"Загружено паттернов: {len(_whole_word_patterns_cache)} whole_word, " + f"{len(_word_start_patterns_cache)} word_start, " f"{len(_contains_patterns_cache)} contains, {len(_exceptions_cache)} исключений" ) else: @@ -51,6 +54,7 @@ def load_bad_words(): _bad_words_cache = data.get('bad_words', []) _exceptions_cache = data.get('exceptions', []) _whole_word_patterns_cache = [] + _word_start_patterns_cache = [] _contains_patterns_cache = _bad_words_cache.copy() logger.info(f"Загружено {len(_bad_words_cache)} бранных слов (старый формат) и {len(_exceptions_cache)} исключений") @@ -114,9 +118,12 @@ def get_exceptions(): def reload_words(): """Перезагружает списки из файла (сбрасывает кэш)""" - global _bad_words_cache, _exceptions_cache + global _bad_words_cache, _exceptions_cache, _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache _bad_words_cache = None _exceptions_cache = None + _whole_word_patterns_cache = None + _word_start_patterns_cache = None + _contains_patterns_cache = None return load_bad_words() def normalize_text(text: str) -> str: @@ -124,6 +131,10 @@ def normalize_text(text: str) -> str: Нормализует текст для обхода обфускации. Убирает: + - Транслитерацию латиницы в кириллицу (xyu → хуй, p0rn → porn) + - Цифры похожие на буквы (0→о, 3→з, 6→б) + - Языковые варианты (ё→е) + - ASCII-имитации (><→х, |\|→и, /\→л) - Звездочки, точки, подчеркивания между буквами (х*й, х.у.й, х_у_й → хуй) - Повторяющиеся символы (хууууууй → хуй) - ОДИНОЧНЫЕ пробелы между ОДИНОЧНЫМИ буквами (х у й → хуй, но "не бу" остаётся "не бу") @@ -140,6 +151,72 @@ def normalize_text(text: str) -> str: # Приводим к нижнему регистру normalized = text.lower() + # 1. Транслитерация латиницы → кириллица (защита от обхода через латиницу) + # Основано на визуальном сходстве букв + transliteration_map = { + 'a': 'а', # a → а + 'b': 'в', # b → в + 'c': 'с', # c → с (латинская c похожа на кириллическую с) + 'd': 'д', # d → д + 'e': 'е', # e → е + 'f': 'ф', # f → ф + 'g': 'г', # g → г + 'h': 'н', # h → н + 'i': 'и', # i → и + 'j': 'ж', # j → ж + 'k': 'к', # k → к + 'l': 'л', # l → л + 'm': 'м', # m → м + 'n': 'н', # n → н + 'o': 'о', # o → о + 'p': 'р', # p → р + 'q': 'к', # q → к + 'r': 'р', # r → р + 's': 'с', # s → с + 't': 'т', # t → т + 'u': 'и', # u → и + 'v': 'в', # v → в + 'w': 'в', # w → в + 'x': 'х', # x → х + 'y': 'у', # y → у + 'z': 'з', # z → з + } + + # Применяем транслитерацию + for lat, cyr in transliteration_map.items(): + normalized = normalized.replace(lat, cyr) + + # 2. Замена цифр на похожие буквы + digit_map = { + '0': 'о', # 0 → о + '1': 'и', # 1 → и (или l) + '3': 'з', # 3 → з + '4': 'ч', # 4 → ч + '6': 'б', # 6 → б + '8': 'в', # 8 → в + } + + for digit, letter in digit_map.items(): + normalized = normalized.replace(digit, letter) + + # 3. Языковые паттерны (унификация написания) + normalized = normalized.replace('ё', 'е') # ё → е + + # 4. ASCII-имитации букв (сложные комбинации символов) + # Важно: делать это ДО удаления других символов + ascii_patterns = [ + (r'\|\|', 'и'), # |\| → и (вертикальные черты) + (r'/\\', 'л'), # /\ → л (треугольник) + (r'><', 'х'), # >< → х + (r'\}\{', 'х'), # }{ → х + (r'\)\(', 'х'), # )( → х + (r'>\|<', 'ж'), # >|< → ж + (r'\}\|\{', 'ж'), # }|{ → ж + ] + + for pattern, replacement in ascii_patterns: + normalized = re.sub(pattern, replacement, normalized) + # Циклически убираем обфускацию, пока что-то меняется max_iterations = 10 for _ in range(max_iterations): @@ -170,11 +247,12 @@ def contains_bad_word(text: str) -> bool: """ Проверяет, содержит ли текст бранные слова. - Использует: + Использует комбинированный подход для минимизации ложных срабатываний: - Нормализацию текста для обхода обфускации - Проверку границ слов для whole_word паттернов - - Проверку подстрок для contains паттернов - - Список исключений + - Проверку начала слова для word_start паттернов (НОВОЕ!) + - Проверку подстрок для contains паттернов с минимальной длиной + - Расширенный список исключений Args: text: Текст для проверки @@ -189,33 +267,72 @@ def contains_bad_word(text: str) -> bool: normalized_text = normalize_text(text) # Получаем паттерны и исключения - global _whole_word_patterns_cache, _contains_patterns_cache, _exceptions_cache + global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache # Если кэш не загружен, загружаем if _whole_word_patterns_cache is None: load_bad_words() whole_word_patterns = _whole_word_patterns_cache or [] + word_start_patterns = _word_start_patterns_cache or [] contains_patterns = _contains_patterns_cache or [] exceptions = _exceptions_cache or [] - # 1. Проверяем whole_word паттерны (только целые слова) + # Вспомогательная функция для проверки исключений + def is_in_exception(pattern_pos, pattern_len, text, exceptions_list): + """Проверяет, входит ли найденный паттерн в слово-исключение""" + for exception in exceptions_list: + # Нормализуем исключение + norm_exception = normalize_text(exception) + if pattern_len == 0 or len(norm_exception) == 0: + continue + + # Ищем исключение в тексте около найденного паттерна + # Проверяем область от (pos - len(exception)) до (pos + len(pattern)) + search_start = max(0, pattern_pos - len(norm_exception)) + search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception)) + search_area = text[search_start:search_end] + + if norm_exception in search_area: + exc_pos = search_area.find(norm_exception) + abs_exc_pos = search_start + exc_pos + abs_exc_end = abs_exc_pos + len(norm_exception) + + # Проверяем, что паттерн полностью внутри исключения + if abs_exc_pos <= pattern_pos < abs_exc_end: + return True + return False + + # 1. Проверяем whole_word паттерны (только целые слова с границами \b) for pattern in whole_word_patterns: # Используем границы слов \b для поиска только целых слов regex = r'\b' + re.escape(pattern) + r'\b' - if re.search(regex, normalized_text, re.IGNORECASE): - # Проверяем, не входит ли в исключения - is_exception = False - for exception in exceptions: - if exception in normalized_text and pattern in exception: - is_exception = True - break - - if not is_exception: + match = re.search(regex, normalized_text, re.IGNORECASE) + if match: + if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions): return True - # 2. Проверяем contains паттерны (любое вхождение) + # 2. Проверяем word_start паттерны (НОВОЕ! только начало слова) + # Паттерн должен быть либо в начале строки, либо после не-буквы + for pattern in word_start_patterns: + # Regex: начало строки ИЛИ не-буква, затем паттерн + # (?:^|(?<=[^а-яёa-z])) - положительный lookbehind для начала или не-буквы + regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern) + match = re.search(regex, normalized_text, re.IGNORECASE) + if match: + if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions): + return True + + # 3. Проверяем contains паттерны (любое вхождение) + # Применяем минимальную длину для снижения false positives + MIN_CONTAINS_LENGTH = 4 + for pattern in contains_patterns: + # Пропускаем слишком короткие паттерны (для безопасности) + if len(pattern) < MIN_CONTAINS_LENGTH: + logger.warning(f"Паттерн '{pattern}' слишком короткий для contains, пропускаем") + continue + if pattern in normalized_text: # Проверяем все вхождения паттерна start = 0 @@ -224,18 +341,7 @@ def contains_bad_word(text: str) -> bool: if pos == -1: break - # Проверяем, не входит ли в исключение - is_exception = False - for exception in exceptions: - if pattern in exception: - exc_start = normalized_text.find(exception, max(0, pos - len(exception))) - if exc_start != -1: - exc_end = exc_start + len(exception) - if exc_start <= pos < exc_end: - is_exception = True - break - - if not is_exception: + if not is_in_exception(pos, len(pattern), normalized_text, exceptions): return True start = pos + 1 @@ -246,11 +352,12 @@ def get_bad_words_from_text(text: str) -> list: """ Возвращает список найденных бранных слов в тексте. - Использует: + Использует комбинированный подход для минимизации ложных срабатываний: - Нормализацию текста для обхода обфускации - Проверку границ слов для whole_word паттернов - - Проверку подстрок для contains паттернов - - Список исключений + - Проверку начала слова для word_start паттернов (НОВОЕ!) + - Проверку подстрок для contains паттернов с минимальной длиной + - Расширенный список исключений Args: text: Текст для проверки @@ -266,35 +373,62 @@ def get_bad_words_from_text(text: str) -> list: found_words = [] # Получаем паттерны и исключения - global _whole_word_patterns_cache, _contains_patterns_cache, _exceptions_cache + global _whole_word_patterns_cache, _word_start_patterns_cache, _contains_patterns_cache, _exceptions_cache # Если кэш не загружен, загружаем if _whole_word_patterns_cache is None: load_bad_words() whole_word_patterns = _whole_word_patterns_cache or [] + word_start_patterns = _word_start_patterns_cache or [] contains_patterns = _contains_patterns_cache or [] exceptions = _exceptions_cache or [] + # Вспомогательная функция для проверки исключений + def is_in_exception(pattern_pos, pattern_len, text, exceptions_list): + """Проверяет, входит ли найденный паттерн в слово-исключение""" + for exception in exceptions_list: + norm_exception = normalize_text(exception) + if pattern_len == 0 or len(norm_exception) == 0: + continue + + search_start = max(0, pattern_pos - len(norm_exception)) + search_end = min(len(text), pattern_pos + pattern_len + len(norm_exception)) + search_area = text[search_start:search_end] + + if norm_exception in search_area: + exc_pos = search_area.find(norm_exception) + abs_exc_pos = search_start + exc_pos + abs_exc_end = abs_exc_pos + len(norm_exception) + + if abs_exc_pos <= pattern_pos < abs_exc_end: + return True + return False + # 1. Проверяем whole_word паттерны (только целые слова) for pattern in whole_word_patterns: - # Используем границы слов \b для поиска только целых слов regex = r'\b' + re.escape(pattern) + r'\b' - if re.search(regex, normalized_text, re.IGNORECASE): - # Проверяем, не входит ли в исключения - is_exception = False - for exception in exceptions: - if exception in normalized_text and pattern in exception: - is_exception = True - break - - if not is_exception and pattern not in found_words: + match = re.search(regex, normalized_text, re.IGNORECASE) + if match: + if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words: found_words.append(pattern) - # 2. Проверяем contains паттерны (любое вхождение) + # 2. Проверяем word_start паттерны (НОВОЕ! только начало слова) + for pattern in word_start_patterns: + regex = r'(?:^|(?<=[^а-яёa-z]))' + re.escape(pattern) + match = re.search(regex, normalized_text, re.IGNORECASE) + if match: + if not is_in_exception(match.start(), len(pattern), normalized_text, exceptions) and pattern not in found_words: + found_words.append(pattern) + + # 3. Проверяем contains паттерны (любое вхождение) + MIN_CONTAINS_LENGTH = 4 + for pattern in contains_patterns: + if len(pattern) < MIN_CONTAINS_LENGTH: + continue + if pattern in normalized_text: - # Проверяем все вхождения паттерна start = 0 word_is_valid = False while True: @@ -302,24 +436,12 @@ def get_bad_words_from_text(text: str) -> list: if pos == -1: break - # Проверяем, не входит ли в исключение - is_exception = False - for exception in exceptions: - if pattern in exception: - exc_start = normalized_text.find(exception, max(0, pos - len(exception))) - if exc_start != -1: - exc_end = exc_start + len(exception) - if exc_start <= pos < exc_end: - is_exception = True - break - - if not is_exception: + if not is_in_exception(pos, len(pattern), normalized_text, exceptions): word_is_valid = True break start = pos + 1 - # Добавляем слово только если оно действительно найдено (не в исключении) if word_is_valid and pattern not in found_words: found_words.append(pattern) diff --git a/src/data/bad_words.json b/src/data/bad_words.json index eecac7f..62ae7fe 100644 --- a/src/data/bad_words.json +++ b/src/data/bad_words.json @@ -19,24 +19,29 @@ "гнида", "гнид" ], - "contains": [ + "word_start": [ "хуй", "хуе", "хуи", "хую", "хуя", "хер", + "бля", "пизд", "пизж", "пезд", "ебал", "ебан", "ебат", - "ебу", "ебош", "ебля", "ебет", - "бля", + "ебут", + "ебуч", + "жоп", + "жёп" + ], + "contains": [ "блядь", "блять", "сука", @@ -64,8 +69,6 @@ "серун", "дрочи", "дроч", - "жоп", - "жёп", "залуп", "ублюдо", "ублюд", @@ -90,7 +93,25 @@ "exceptions": [ "республика", "документ", - "документы" + "документы", + "требует", + "требуется", + "требую", + "требуем", + "требуют", + "требовать", + "требование", + "потребует", + "потребуется", + "употреблять", + "употребляю", + "употребление", + "потребление", + "требуха", + "скребу", + "скребут", + "гребу", + "гребут" ], - "_comment": "whole_word - только целые слова (не часть другого слова), contains - любое вхождение подстроки" + "_comment": "whole_word - только целые слова с границами \\b, word_start - только начало слова, contains - любое вхождение (мин. 4 буквы в коде)" } \ No newline at end of file diff --git a/src/database.py b/src/database.py index 4585c22..bf57221 100644 --- a/src/database.py +++ b/src/database.py @@ -110,6 +110,15 @@ class Database: # Инициализация класса ON message_cache(timestamp) ''') + # Таблица для отключенных топиков (где не начисляется карма) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS disabled_karma_topics ( + chat_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ) + ''') + connect.commit() logger.info("База данных и индексы успешно инициализированы") @@ -460,5 +469,99 @@ class Database: # Инициализация класса result = cursor.fetchone() return result[0] if result else 0 + # Проверяет, отключена ли карма в топике + def is_karma_disabled_in_topic(self, chat_id: int, thread_id: Optional[int]) -> bool: + """ + Проверяет, отключена ли карма в данном топике. + + Args: + chat_id: ID чата + thread_id: ID топика (None для главного чата) + + Returns: + True если карма отключена, иначе False + """ + # Если thread_id None, считаем что это главный чат (карма всегда включена) + if thread_id is None: + return False + + with self._get_connection() as connect: + cursor = connect.cursor() + cursor.execute(''' + SELECT 1 FROM disabled_karma_topics + WHERE chat_id = ? AND thread_id = ? + ''', (chat_id, thread_id)) + return cursor.fetchone() is not None + + # Отключает карму в топике + def disable_karma_in_topic(self, chat_id: int, thread_id: int) -> bool: + """ + Отключает начисление кармы в данном топике. + + Args: + chat_id: ID чата + thread_id: ID топика + + Returns: + True если успешно, False если уже было отключено + """ + with self._get_connection() as connect: + cursor = connect.cursor() + + # Проверяем, не отключено ли уже + if self.is_karma_disabled_in_topic(chat_id, thread_id): + return False + + cursor.execute(''' + INSERT INTO disabled_karma_topics (chat_id, thread_id) + VALUES (?, ?) + ''', (chat_id, thread_id)) + connect.commit() + logger.info(f"Карма отключена в топике {thread_id} чата {chat_id}") + return True + + # Включает карму в топике + def enable_karma_in_topic(self, chat_id: int, thread_id: int) -> bool: + """ + Включает начисление кармы в данном топике. + + Args: + chat_id: ID чата + thread_id: ID топика + + Returns: + True если успешно, False если уже было включено + """ + with self._get_connection() as connect: + cursor = connect.cursor() + cursor.execute(''' + DELETE FROM disabled_karma_topics + WHERE chat_id = ? AND thread_id = ? + ''', (chat_id, thread_id)) + deleted = cursor.rowcount > 0 + connect.commit() + if deleted: + logger.info(f"Карма включена в топике {thread_id} чата {chat_id}") + return deleted + + # Получает список отключенных топиков для чата + def get_disabled_topics(self, chat_id: int) -> list: + """ + Возвращает список ID топиков, где отключена карма. + + Args: + chat_id: ID чата + + Returns: + Список ID топиков + """ + with self._get_connection() as connect: + cursor = connect.cursor() + cursor.execute(''' + SELECT thread_id FROM disabled_karma_topics + WHERE chat_id = ? + ''', (chat_id,)) + return [row[0] for row in cursor.fetchall()] + # Создаем экземпляр базы данных для импорта в других модулях db = Database() \ No newline at end of file diff --git a/src/modules/0_karma_tracker.py b/src/modules/0_karma_tracker.py index aed6ef9..1bc9252 100644 --- a/src/modules/0_karma_tracker.py +++ b/src/modules/0_karma_tracker.py @@ -53,6 +53,7 @@ def register_handlers(bot: AsyncTeleBot): - Поставил 🔥 → +2 кармы | Убрал 🔥 → -2 кармы - Поставил ❤ → +5 кармы | Убрал ❤ → -5 кармы - Поставил ❤‍🔥 → +10 кармы | Убрал ❤‍🔥 → -10 кармы + - Поставил 🤡 → -20 кармы ПОСТАВИВШЕМУ (не возвращается при снятии!) """ try: logger.info(f"[KARMA] Получена реакция от {reaction.user.id}") @@ -73,6 +74,11 @@ def register_handlers(bot: AsyncTeleBot): to_user_id, message_thread_id = cached_data + # Проверяем, отключена ли карма в этом топике + if db.is_karma_disabled_in_topic(chat_id, message_thread_id): + logger.info(f"[KARMA] Пропуск реакции - карма отключена в топике {message_thread_id}") + return + # Защита от самооценки if from_user.id == to_user_id: logger.info(f"Пользователь {from_user.id} попытался поставить реакцию на своё сообщение") @@ -94,6 +100,7 @@ def register_handlers(bot: AsyncTeleBot): old_heart = False old_fire_heart = False old_fire = False + old_clown = False if reaction.old_reaction: for react in reaction.old_reaction: if isinstance(react, ReactionTypeEmoji): @@ -107,6 +114,8 @@ def register_handlers(bot: AsyncTeleBot): old_fire_heart = True elif react.emoji == "🔥": old_fire = True + elif react.emoji == "🤡": + old_clown = True # Проверяем новые реакции new_thumbs_up = False @@ -114,6 +123,7 @@ def register_handlers(bot: AsyncTeleBot): new_heart = False new_fire_heart = False new_fire = False + new_clown = False if reaction.new_reaction: for react in reaction.new_reaction: if isinstance(react, ReactionTypeEmoji): @@ -127,6 +137,8 @@ def register_handlers(bot: AsyncTeleBot): new_fire_heart = True elif react.emoji == "🔥": new_fire = True + elif react.emoji == "🤡": + new_clown = True # Определяем изменение кармы (накапливаем все изменения) karma_change = 0 @@ -175,19 +187,63 @@ def register_handlers(bot: AsyncTeleBot): karma_change -= 2 actions.append("убрал 🔥 (-2)") + # Проверяем 🤡 (ОСОБАЯ ЛОГИКА: минусует карму поставившему!) + clown_penalty = 0 + if new_clown and not old_clown: + # Поставил клоуна → минус 20 кармы ПОСТАВИВШЕМУ + clown_penalty = -20 + logger.info(f"[KARMA] Пользователь {from_user.id} поставил клоуна → -20 кармы ему самому") + # При снятии клоуна ничего не делаем (карма не возвращается) + # Если нет изменений - выходим - if karma_change == 0: + if karma_change == 0 and clown_penalty == 0: logger.info(f"[KARMA] Нет изменений в реакциях") return # Формируем текст действий для логирования - action_text = ", ".join(actions) + action_text = ", ".join(actions) if actions else "" logger.info(f"[KARMA] {action_text} от {from_user.id} для {to_user_id}, итоговое изменение кармы: {karma_change}") - # Изменяем карму - db.add_karma(to_user_id, chat_id, karma_change) + # Изменяем карму получателю (если есть изменения) + if karma_change != 0: + db.add_karma(to_user_id, chat_id, karma_change) - # Получаем новую карму + # Применяем штраф за клоуна к ПОСТАВИВШЕМУ + if clown_penalty != 0: + db.add_karma(from_user.id, chat_id, clown_penalty) + clown_karma = db.get_karma(from_user.id, chat_id) + + # Формируем имя поставившего + from_user_name = from_user.first_name + from_user_display = f"@{from_user.username}" if from_user.username else from_user_name + + # Отправляем уведомление о штрафе за клоуна + clown_message = f"🤡 {from_user_display} поставил клоуна и получил -20 кармы! Текущая карма: {clown_karma}" + try: + sent_clown_msg = await bot.send_message( + chat_id, + clown_message, + message_thread_id=message_thread_id + ) + logger.info(f"[KARMA] Уведомление о клоуне отправлено") + + # Удаляем уведомление через 15 секунд + async def delete_clown_notification(): + try: + await asyncio.sleep(15) + await bot.delete_message(chat_id, sent_clown_msg.message_id) + except Exception as e: + logger.error(f"Не удалось удалить уведомление о клоуне: {e}") + + asyncio.create_task(delete_clown_notification()) + except Exception as e: + logger.error(f"Ошибка отправки уведомления о клоуне: {e}", exc_info=True) + + # Если нет обычных изменений кармы - выходим (клоун уже обработан) + if karma_change == 0: + return + + # Получаем новую карму получателя new_karma = db.get_karma(to_user_id, chat_id) # Формируем имя пользователя (из БД: id, nickname, tag) @@ -251,6 +307,11 @@ def register_handlers(bot: AsyncTeleBot): logger.info(f"[KARMA] Пропуск - не групповой чат: {message.chat.type}") return + # Проверяем, отключена ли карма в этом топике + if db.is_karma_disabled_in_topic(message.chat.id, message.message_thread_id): + logger.info(f"[KARMA] Пропуск благодарности - карма отключена в топике {message.message_thread_id}") + return + # ВАЖНО: В топиках каждое сообщение технически является reply на первое сообщение топика # Проверяем, что это реальный reply на сообщение пользователя, а не просто сообщение в топике if message.is_topic_message and message.reply_to_message.message_id == message.message_thread_id: diff --git a/src/modules/badwords_manager.py b/src/modules/badwords_manager.py index 165682f..0186a88 100644 --- a/src/modules/badwords_manager.py +++ b/src/modules/badwords_manager.py @@ -8,8 +8,10 @@ from bad_words import ( save_bad_words, reload_words ) +from database import db from utils import check_admin_status, delete_messages from config import COMMAND_MESSAGES +from action_reporter import action_reporter logger = logging.getLogger(__name__) @@ -139,6 +141,16 @@ async def add_bad_word(bot: AsyncTeleBot, message: Message, word: str): reload_words() # Перезагружаем кэш await send_temp_message(bot, message, f"✅ Слово '{word}' добавлено в список бранных.") logger.info(f"Администратор {message.from_user.id} добавил бранное слово: {word}") + + # Отправляем в канал аудита + if action_reporter: + await action_reporter.log_action( + action=f"Добавление бранного слова", + user_id=None, # Не применимо к конкретному пользователю + admin_id=message.from_user.id, + reason=f"Слово: {word}", + duration=None, + ) else: await send_temp_message(bot, message, "❌ Ошибка при сохранении файла.") @@ -158,6 +170,16 @@ async def remove_bad_word(bot: AsyncTeleBot, message: Message, word: str): reload_words() # Перезагружаем кэш await send_temp_message(bot, message, f"✅ Слово '{word}' удалено из списка бранных.") logger.info(f"Администратор {message.from_user.id} удалил бранное слово: {word}") + + # Отправляем в канал аудита + if action_reporter: + await action_reporter.log_action( + action=f"Удаление бранного слова", + user_id=None, + admin_id=message.from_user.id, + reason=f"Слово: {word}", + duration=None, + ) else: await send_temp_message(bot, message, "❌ Ошибка при сохранении файла.") @@ -194,6 +216,16 @@ async def add_exception(bot: AsyncTeleBot, message: Message, word: str): reload_words() # Перезагружаем кэш await send_temp_message(bot, message, f"✅ Слово '{word}' добавлено в исключения.") logger.info(f"Администратор {message.from_user.id} добавил исключение: {word}") + + # Отправляем в канал аудита + if action_reporter: + await action_reporter.log_action( + action=f"Добавление исключения", + user_id=None, + admin_id=message.from_user.id, + reason=f"Слово: {word}", + duration=None, + ) else: await send_temp_message(bot, message, "❌ Ошибка при сохранении файла.") @@ -213,6 +245,16 @@ async def remove_exception(bot: AsyncTeleBot, message: Message, word: str): reload_words() # Перезагружаем кэш await send_temp_message(bot, message, f"✅ Слово '{word}' удалено из исключений.") logger.info(f"Администратор {message.from_user.id} удалил исключение: {word}") + + # Отправляем в канал аудита + if action_reporter: + await action_reporter.log_action( + action=f"Удаление исключения", + user_id=None, + admin_id=message.from_user.id, + reason=f"Слово: {word}", + duration=None, + ) else: await send_temp_message(bot, message, "❌ Ошибка при сохранении файла.") diff --git a/src/modules/karma_commands.py b/src/modules/karma_commands.py index 2d0222a..23a5107 100644 --- a/src/modules/karma_commands.py +++ b/src/modules/karma_commands.py @@ -4,6 +4,7 @@ import asyncio import logging from database import db +from utils import check_admin_status, delete_messages logger = logging.getLogger(__name__) @@ -21,11 +22,14 @@ def register_handlers(bot: AsyncTeleBot): @bot.message_handler(commands=['karma', 'rating']) async def handle_karma_command(message: Message): """ - Команда /karma - показывает карму пользователя + Команда /karma - показывает карму пользователя или управляет настройками кармы Использование: /karma - показать свою карму /karma @username - показать карму пользователя /karma (в ответ на сообщение) - показать карму автора сообщения + /karma disable - отключить карму в топике (только админы) + /karma enable - включить карму в топике (только админы) + /karma status - показать статус кармы в топиках (только админы) """ try: # Проверяем, что это групповой чат @@ -34,6 +38,31 @@ def register_handlers(bot: AsyncTeleBot): return chat_id = message.chat.id + parts = message.text.split(maxsplit=1) + + # Проверяем субкоманды для админов + if len(parts) > 1: + subcommand = parts[1].lower() + + # Субкоманды управления кармой (только для админов) + if subcommand in ['disable', 'enable', 'status']: + # Проверяем права администратора + admin_check = await check_admin_status(bot, message, check_restrict_rights=False) + if admin_check == 1: + logger.info(f"Пользователь {message.from_user.id} не является администратором") + return + + if subcommand == 'disable': + await disable_karma_in_topic(bot, message) + return + elif subcommand == 'enable': + await enable_karma_in_topic(bot, message) + return + elif subcommand == 'status': + await show_karma_status(bot, message) + return + + # Обычная логика показа кармы target_user = None target_user_id = None @@ -43,8 +72,8 @@ def register_handlers(bot: AsyncTeleBot): target_user_id = target_user.id # Если указан username в команде - elif len(message.text.split()) > 1: - username_arg = message.text.split()[1] + elif len(parts) > 1: + username_arg = parts[1] # Убираем @ если есть username = username_arg.lstrip('@') @@ -136,9 +165,9 @@ def register_handlers(bot: AsyncTeleBot): else: medal = f"{idx}." - # Формируем отображение пользователя + # Формируем отображение пользователя (без упоминаний) if tag: - user_display = f"@{tag}" + user_display = f"@{tag}" else: user_display = nickname or f"ID: {user_id}" @@ -152,4 +181,69 @@ def register_handlers(bot: AsyncTeleBot): except Exception as e: logger.error(f"Ошибка при обработке команды /top: {e}", exc_info=True) - await bot.reply_to(message, "❌ Произошла ошибка при получении топа") \ No newline at end of file + await bot.reply_to(message, "❌ Произошла ошибка при получении топа") + +async def disable_karma_in_topic(bot: AsyncTeleBot, message: Message): + """Отключает начисление кармы в текущем топике""" + # Проверяем, что команда вызвана в топике + if not message.is_topic_message: + await send_temp_message(bot, message, "❌ Эта команда работает только в топиках!") + return + + thread_id = message.message_thread_id + chat_id = message.chat.id + + # Отключаем карму в топике + if db.disable_karma_in_topic(chat_id, thread_id): + await send_temp_message( + bot, message, + f"🚫 Начисление кармы отключено в этом топике (ID: {thread_id})" + ) + logger.info(f"Администратор {message.from_user.id} отключил карму в топике {thread_id}") + else: + await send_temp_message(bot, message, "⚠️ Карма уже отключена в этом топике") + +async def enable_karma_in_topic(bot: AsyncTeleBot, message: Message): + """Включает начисление кармы в текущем топике""" + # Проверяем, что команда вызвана в топике + if not message.is_topic_message: + await send_temp_message(bot, message, "❌ Эта команда работает только в топиках!") + return + + thread_id = message.message_thread_id + chat_id = message.chat.id + + # Включаем карму в топике + if db.enable_karma_in_topic(chat_id, thread_id): + await send_temp_message( + bot, message, + f"✅ Начисление кармы включено в этом топике (ID: {thread_id})" + ) + logger.info(f"Администратор {message.from_user.id} включил карму в топике {thread_id}") + else: + await send_temp_message(bot, message, "⚠️ Карма уже включена в этом топике") + +async def show_karma_status(bot: AsyncTeleBot, message: Message): + """Показывает статус кармы во всех топиках чата""" + chat_id = message.chat.id + + # Получаем список отключенных топиков + disabled_topics = db.get_disabled_topics(chat_id) + + if not disabled_topics: + text = "✅ Карма включена во всех топиках" + else: + text = f"📊 Статус кармы в топиках\n\n" + text += f"🚫 Отключено в топиках (ID): {', '.join(map(str, disabled_topics))}\n" + text += f"\n💡 Используйте /karma enable в топике, чтобы включить карму" + + await send_temp_message(bot, message, text, time_sleep=20) + +async def send_temp_message(bot: AsyncTeleBot, message: Message, text: str, time_sleep: int = 10): + """Отправляет временное сообщение, которое удаляется через указанное время""" + await bot.send_message( + chat_id=message.chat.id, + text=text, + message_thread_id=message.message_thread_id, + ) + await delete_messages(bot, message, time_sleep=time_sleep, number_message=2) \ No newline at end of file