diff --git a/python/README.md b/python/README.md index a50c2d69..6e57893d 100644 --- a/python/README.md +++ b/python/README.md @@ -17,16 +17,25 @@ | ✔️ | top | Вывести информацию о участниках беседы в порядке уменьшения кармы. | | ✔️ | top [ЯЗЫКИ] | Вывести информацию о участниках беседы с указанными языками в порядке уменьшения кармы. | | ✔️ | top [ЧИСЛО] | Вывести информацию об указанном числе участников беседы в порядке уменьшения кармы. | +| ✔️ | local top | Вывести информацию о участниках беседы в порядке уменьшения локальной кармы в текущем чате. | +| ✔️ | local top [ЧИСЛО] | Вывести информацию об указанном числе участников беседы в порядке уменьшения локальной кармы в текущем чате. | | ✔️ | bottom | Вывести информацию о участниках беседы в порядке увеличения кармы. | | ✔️ | bottom [ЯЗЫКИ] | Вывести информацию о участниках беседы с указанными языками в порядке увеличения кармы. | | ✔️ | bottom [ЧИСЛО] | Вывести информацию об указанном числе участников беседы беседы в порядке увеличения кармы. | +| ✔️ | local bottom | Вывести информацию о участниках беседы в порядке увеличения локальной кармы в текущем чате. | +| ✔️ | local bottom [ЧИСЛО] | Вывести информацию об указанном числе участников беседы в порядке увеличения локальной кармы в текущем чате. | | ✔️ | karma | Вывод своей кармы или кармы участника беседы из пересланного сообщения. | +| ✔️ | local karma | Вывод своей локальной кармы в текущем чате или локальной кармы участника беседы из пересланного сообщения. | | ⭐ | info | Вывести общую информацию (карма (только для бесед с кармой), добавленные языки, ссылка на профиль github) о себе или участнике беседы из пересланного сообщения. | | ⭐ | update | Обновить информацию о вас (имя). Эта команда так же выводит информацию о вас как это делает команда info. | | ✔️ | + | Проголосовать за повышение кармы участника беседы из пересланного сообщения. | | ✔️ | - | Проголосовать за понижение кармы участника беседы из пересланного сообщения. | | ✔️ | +[ЧИСЛО] | Повысить карму участника беседы из пересланного сообщения на указанное число, потратив свою. | | ✔️ | -[ЧИСЛО] | Понизить карму участника беседы из пересланного сообщения на указанное число, потратив свою. | +| ✔️ | local + | Повысить локальную карму участника беседы из пересланного сообщения в текущем чате. | +| ✔️ | local - | Понизить локальную карму участника беседы из пересланного сообщения в текущем чате. | +| ✔️ | local +[ЧИСЛО] | Повысить локальную карму участника беседы из пересланного сообщения на указанное число в текущем чате. | +| ✔️ | local -[ЧИСЛО] | Понизить локальную карму участника беседы из пересланного сообщения на указанное число в текущем чате. | | ⭐ | += [ЯЗЫК] | Добавить язык программирования в свой профиль. | | ⭐ | -= [ЯЗЫК] | Убрать язык программирования из своего профиля. | | ⭐ | += [ССЫЛКА] | Добавить ссылку на профиль github в свой профиль. | @@ -42,6 +51,9 @@ | top | топ | верх | | bottom | дно | низ | | karma | карма| +| local karma | локальная карма | местная карма | лкарма | +| local top | локальный топ | местный топ | лтоп | +| local bottom | локальный низ | местный низ | лдно | | info | инфо | | update | обновить | | what is | что такое | diff --git a/python/__main__.py b/python/__main__.py index cdcbf7f6..71f1f70a 100644 --- a/python/__main__.py +++ b/python/__main__.py @@ -52,10 +52,14 @@ def __init__( (patterns.REMOVE_GITHUB_PROFILE, lambda: self.commands.change_github_profile(False)), (patterns.KARMA, self.commands.karma_message), + (patterns.LOCAL_KARMA, self.commands.local_karma_message), (patterns.TOP, self.commands.top), + (patterns.LOCAL_TOP, self.commands.local_top), (patterns.PEOPLE, self.commands.top), (patterns.BOTTOM, lambda: self.commands.top(True)), + (patterns.LOCAL_BOTTOM, + lambda: self.commands.local_top(True)), (patterns.TOP_LANGUAGES, self.commands.top_langs), (patterns.PEOPLE_LANGUAGES, self.commands.top_langs), (patterns.BOTTOM_LANGUAGES, @@ -63,6 +67,7 @@ def __init__( (patterns.WHAT_IS, self.commands.what_is), (patterns.WHAT_MEAN, self.commands.what_is), (patterns.APPLY_KARMA, self.commands.apply_karma), + (patterns.APPLY_LOCAL_KARMA, self.commands.apply_local_karma), (patterns.GITHUB_COPILOT, self.commands.github_copilot) ) diff --git a/python/modules/commands.py b/python/modules/commands.py index 93d99817..5f4a69a8 100644 --- a/python/modules/commands.py +++ b/python/modules/commands.py @@ -308,6 +308,102 @@ def apply_user_karma( user.karma = new_karma return (user.uid, user.name, initial_karma, new_karma) + def local_karma_message(self) -> NoReturn: + """Shows user's local karma for current chat.""" + if self.peer_id < 2e9 and not self.karma_enabled: + return + is_self = self.user.uid == self.from_id + self.vk_instance.send_msg( + CommandsBuilder.build_local_karma(self.user, self.data_service, is_self, self.peer_id), + self.peer_id) + + def apply_local_karma(self) -> NoReturn: + """Changes user local karma for current chat.""" + if self.peer_id < 2e9 or not self.karma_enabled or not self.matched or self.is_bot_selected: + return + + operator = self.matched.group("operator") + amount_string = self.matched.group("amount") + amount = int(amount_string) if amount_string else 1 + + if amount > 10: + return + + selected_user_id = self.matched.group("selectedUserId") + selected_user = self.data_service.get_user( + int(selected_user_id), self.vk_instance) if selected_user_id else self.user + + if selected_user.uid == self.from_id: + return + + if selected_user.uid == config.BOT_GROUP_ID: + self.is_bot_selected = True + return + + if operator == "-": + # Downvotes disabled for users with negative local karma + if self.data_service.get_local_karma(self.current_user, self.peer_id) < 0: + self.vk_instance.send_msg( + CommandsBuilder.build_not_enough_local_karma(self.current_user, self.data_service, self.peer_id), + self.peer_id) + return + + current_time = datetime.now() + last_vote_time = self.current_user.last_collective_vote + + if last_vote_time > 0: + time_diff = (current_time - datetime.fromtimestamp(last_vote_time)).total_seconds() / 3600 + hours_limit = karma_limit( + self.data_service.get_local_karma(self.current_user, self.peer_id)) + + if time_diff < hours_limit: + return + + # Apply local karma change + local_karma_change = self.apply_user_local_karma(selected_user, amount if operator == "+" else -amount) + + if local_karma_change: + self.current_user.last_collective_vote = current_time.timestamp() + self.data_service.save_user(self.current_user) + self.data_service.save_user(selected_user) + self.vk_instance.send_msg( + CommandsBuilder.build_local_karma_change(local_karma_change, self.peer_id), + self.peer_id) + + def apply_user_local_karma( + self, + user: BetterUser, + amount: int + ) -> Optional[Tuple[int, str, int, int]]: + """Changes user local karma for current chat + + :param user: user object + :param amount: karma amount to change + :return: tuple of (user_id, username, initial_karma, new_karma) or None + """ + initial_karma = self.data_service.get_local_karma(user, self.peer_id) + new_karma = initial_karma + amount + self.data_service.set_local_karma(user, self.peer_id, new_karma) + return (user.uid, user.name, initial_karma, new_karma) + + def local_top(self, reverse: bool = False) -> NoReturn: + """Sends users local karma top for current chat.""" + if self.peer_id < 2e9: + return + maximum_users = self.matched.group("maximum_users") + maximum_users = int(maximum_users) if maximum_users else -1 + users = DataBuilder.get_users_sorted_by_local_karma( + self.vk_instance, self.data_service, self.peer_id, self.peer_id) + users = [user for user in users if user["local_karma"] != 0 or not reverse] + if maximum_users != -1: + users = users[:maximum_users] + if reverse: + users.reverse() + self.vk_instance.send_msg( + CommandsBuilder.build_local_top_users( + users, self.data_service, reverse, self.karma_enabled, maximum_users), + self.peer_id) + def what_is(self) -> NoReturn: """Search on wikipedia and sends if available""" question = self.matched.groups() diff --git a/python/modules/commands_builder.py b/python/modules/commands_builder.py index 29dc739f..95f25410 100644 --- a/python/modules/commands_builder.py +++ b/python/modules/commands_builder.py @@ -26,6 +26,11 @@ def build_help_message( elif peer_id > 2e9: if karma: return ("Вы находитесь в беседе с включённой кармой.\n" + "Доступные команды для локальной кармы:\n" + "• 'local karma' / 'локальная карма' - показать локальную карму\n" + "• 'local +/-' - изменить локальную карму пользователя\n" + "• 'local top' / 'локальный топ' - топ по локальной карме\n" + "• 'local bottom' / 'локальный низ' - низ по локальной карме\n" f"Документация — {documentation_link}") else: return (f"Вы находитесь в беседе (#{peer_id}) с выключенной кармой.\n" @@ -185,3 +190,76 @@ def build_karma_change( return ("Карма изменена: [id%s|%s] [%s]->[%s]. Голосовали: (%s)" % (selected_user_karma_change + (", ".join([f"@id{voter}" for voter in voters]),))) return None + + @staticmethod + def build_local_karma( + user: BetterUser, + data: BetterBotBaseDataService, + is_self: bool, + chat_id: int + ) -> str: + """Sends user local karma amount for specific chat. + """ + if is_self: + return (f"Ваша локальная карма в этом чате — " + f"{DataBuilder.build_local_karma(user, data, chat_id)}.") + else: + mention = f"[id{user.uid}|{user.name}]" + return (f"Локальная карма {mention} в этом чате — " + f"{DataBuilder.build_local_karma(user, data, chat_id)}.") + + @staticmethod + def build_not_enough_local_karma( + user: BetterUser, + data: BetterBotBaseDataService, + chat_id: int + ) -> str: + """Builds message about insufficient local karma.""" + return (f"Вы не можете минусовать карму, " + f"но Вашей локальной кармы [{data.get_local_karma(user, chat_id)}] " + f"в этом чате недостаточно.") + + @staticmethod + def build_local_karma_change( + local_karma_change: Tuple[int, str, int, int], + chat_id: int + ) -> str: + """Builds local karma changing message.""" + return ("Локальная карма в этом чате изменена: [id%s|%s] [%s]->[%s]." % + local_karma_change) + + @staticmethod + def build_local_top_users( + users: List[Dict[str, Any]], + data: BetterBotBaseDataService, + reverse: bool = False, + has_karma: bool = True, + maximum_users: int = -1 + ) -> Optional[str]: + """Builds local karma top users list.""" + if not users: + return None + if reverse: + users = list(reversed(users)) + user_strings = [] + for user in users: + karma_str = f"[{user['local_karma']}]" if has_karma else "" + user_string = (f"{karma_str} " + f"[id{user['uid']}|{user['name']}]" + f"{DataBuilder.build_github_profile_from_dict(user, ' - ')}" + f"{DataBuilder.build_programming_languages_from_dict(user, '')}") + user_strings.append(user_string) + + total_symbols = 0 + i = 0 + for user_string in user_strings: + user_string_length = len(user_string) + if (total_symbols + user_string_length + 2) >= 4096: # Maximum message size for VK API (messages.send) + user_strings = user_strings[:i] + break + else: + total_symbols += user_string_length + 2 + i += 1 + if maximum_users > 0: + return '\n'.join(user_strings[:maximum_users]) + return '\n'.join(user_strings) diff --git a/python/modules/data_builder.py b/python/modules/data_builder.py index c594f7e1..c9109506 100644 --- a/python/modules/data_builder.py +++ b/python/modules/data_builder.py @@ -97,3 +97,66 @@ def calculate_real_karma( up_votes = len(user["supporters"])/config.POSITIVE_VOTES_PER_KARMA down_votes = len(user["opponents"])/config.NEGATIVE_VOTES_PER_KARMA return base_karma + up_votes - down_votes + + @staticmethod + def build_local_karma( + user: BetterUser, + data: BetterBotBaseDataService, + chat_id: int + ) -> str: + """Builds the user's local karma for specific chat and returns its string representation. + """ + local_karma = data.get_local_karma(user, chat_id) + plus_string = "" + minus_string = "" + up_votes = len(user["supporters"]) + down_votes = len(user["opponents"]) + if up_votes > 0: + plus_string = "+%.1f" % (up_votes / config.POSITIVE_VOTES_PER_KARMA) + if down_votes > 0: + minus_string = "-%.1f" % (down_votes / config.NEGATIVE_VOTES_PER_KARMA) + if up_votes > 0 or down_votes > 0: + return f"[{local_karma}][{plus_string}{minus_string}]" + else: + return f"[{local_karma}]" + + @staticmethod + def get_users_sorted_by_local_karma( + vk_instance: Vk, + data: BetterBotBaseDataService, + peer_id: int, + chat_id: int + ) -> List[Dict[str, Any]]: + """Returns users from the chat sorted by local karma.""" + members = vk_instance.get_members_ids(peer_id) + users = [] + for member_id in members: + user = data.get_user(member_id) + local_karma = data.get_local_karma(user, chat_id) + users.append({ + "uid": member_id, + "local_karma": local_karma, + "name": user.name, + "programming_languages": user.programming_languages, + "github_profile": user.github_profile + }) + return sorted(users, key=lambda u: u["local_karma"], reverse=True) + + @staticmethod + def build_programming_languages_from_dict( + user_dict: Dict[str, Any], + default: str = "отсутствуют" + ) -> str: + """Builds programming languages from user dictionary.""" + languages = user_dict.get("programming_languages", []) + languages = languages if isinstance(languages, list) else [] + return ", ".join(sorted(languages)) if len(languages) > 0 else default + + @staticmethod + def build_github_profile_from_dict( + user_dict: Dict[str, Any], + prefix: str = "" + ) -> str: + """Builds github profile from user dictionary.""" + profile = user_dict.get("github_profile", "") + return f"{prefix}github.com/{profile}" if profile else "" diff --git a/python/modules/data_service.py b/python/modules/data_service.py index 45a0faeb..130163f8 100644 --- a/python/modules/data_service.py +++ b/python/modules/data_service.py @@ -19,6 +19,7 @@ def __init__(self, db_name: str = "users"): self.base.addPattern("supporters", []) self.base.addPattern("opponents", []) self.base.addPattern("karma", 0) + self.base.addPattern("local_karma", {}) def get_or_create_user( self, @@ -115,3 +116,35 @@ def save_user( user: BetterUser ) -> NoReturn: self.base.save(user) + + @staticmethod + def get_local_karma( + user: Union[Dict[str, Any], BetterUser], + chat_id: int + ) -> int: + """Get user's karma for specific chat. + + :param user: dict or BetterUser + :param chat_id: chat identifier + :return: karma value for the chat + """ + local_karma_dict = BetterBotBaseDataService.get_user_property(user, "local_karma") + return local_karma_dict.get(str(chat_id), 0) + + @staticmethod + def set_local_karma( + user: Union[Dict[str, Any], BetterUser], + chat_id: int, + karma_value: int + ) -> NoReturn: + """Set user's karma for specific chat. + + :param user: dict or BetterUser + :param chat_id: chat identifier + :param karma_value: new karma value + """ + local_karma_dict = BetterBotBaseDataService.get_user_property(user, "local_karma") + if local_karma_dict is None: + local_karma_dict = {} + local_karma_dict[str(chat_id)] = karma_value + BetterBotBaseDataService.set_user_property(user, "local_karma", local_karma_dict) diff --git a/python/patterns.py b/python/patterns.py index 1834c72c..824fd0b1 100644 --- a/python/patterns.py +++ b/python/patterns.py @@ -18,9 +18,15 @@ KARMA = recompile( r'\A\s*(карма|karma)\s*\Z', IGNORECASE) +LOCAL_KARMA = recompile( + r'\A\s*(локальная карма|местная карма|local karma|лкарма|lkarma)\s*\Z', IGNORECASE) + APPLY_KARMA = recompile( r'\A(\[id(?\d+)\|@\w+\])?\s*(?P\+|\-)(?P[0-9]*)\s*\Z') +APPLY_LOCAL_KARMA = recompile( + r'\A(\[id(?\d+)\|@\w+\])?\s*local\s*(?P\+|\-)(?P[0-9]*)\s*\Z', IGNORECASE) + ADD_PROGRAMMING_LANGUAGE = recompile( r'\A\s*\+=\s*(?P' + DEFAULT_LANGUAGES + r')\s*\Z', IGNORECASE) @@ -36,9 +42,15 @@ TOP = recompile( r'\A\s*(топ|верх|top)\s*(?P\d+)?\s*\Z', IGNORECASE) +LOCAL_TOP = recompile( + r'\A\s*(локальный топ|местный топ|local top|лтоп|ltop)\s*(?P\d+)?\s*\Z', IGNORECASE) + BOTTOM = recompile( r'\A\s*(низ|дно|bottom)\s*(?P\d+)?\s*\Z', IGNORECASE) +LOCAL_BOTTOM = recompile( + r'\A\s*(локальный низ|местный низ|local bottom|лдно|lbottom)\s*(?P\d+)?\s*\Z', IGNORECASE) + TOP_LANGUAGES = recompile( r'\A\s*(топ|верх|top)\s*(?P\d+\s+)?\s*(?P(' + DEFAULT_LANGUAGES + r')(\s+(' + DEFAULT_LANGUAGES + r'))*)\s*\Z', IGNORECASE) diff --git a/python/tests.py b/python/tests.py index 4be4241e..cb638f3a 100644 --- a/python/tests.py +++ b/python/tests.py @@ -223,6 +223,95 @@ def test_apply_karma_change( self.commands.karma_message() +class Test4LocalKarma(TestCase): + """TestCase for local karma functionality + """ + db = BetterBotBaseDataService('test_local_karma_db') + + @ordered + def test_local_karma_data_structure(self) -> NoReturn: + """Test local karma data storage and retrieval""" + user = self.db.get_or_create_user(1, None) + chat_id = 2000000001 + + # Initially, local karma should be 0 + assert self.db.get_local_karma(user, chat_id) == 0 + + # Set local karma + self.db.set_local_karma(user, chat_id, 5) + assert self.db.get_local_karma(user, chat_id) == 5 + + # Test different chat has different karma + chat_id_2 = 2000000002 + assert self.db.get_local_karma(user, chat_id_2) == 0 + + self.db.set_local_karma(user, chat_id_2, 10) + assert self.db.get_local_karma(user, chat_id) == 5 # First chat unchanged + assert self.db.get_local_karma(user, chat_id_2) == 10 # Second chat + + @ordered + def test_build_local_karma(self) -> NoReturn: + """Test local karma string building""" + user = self.db.get_user(1) + chat_id = 2000000001 + + # Test with zero karma + karma_str = DataBuilder.build_local_karma(user, self.db, chat_id) + assert karma_str == "[5]" + + # Test with different karma value + self.db.set_local_karma(user, chat_id, 15) + karma_str = DataBuilder.build_local_karma(user, self.db, chat_id) + assert karma_str == "[15]" + + @ordered + def test_local_karma_commands(self) -> NoReturn: + """Test local karma command functionality""" + commands = Commands(VkInstance(), self.db) + commands.peer_id = 2_000_000_001 + commands.karma_enabled = True + commands.current_user = self.db.get_user(1) + commands.user = self.db.get_user(1) + + # Test local karma message + commands.local_karma_message() + + # Test local karma application (simulate) + initial_karma = self.db.get_local_karma(commands.user, commands.peer_id) + result = commands.apply_user_local_karma(commands.user, 3) + assert result[2] == initial_karma # initial karma + assert result[3] == initial_karma + 3 # new karma + + # Verify karma was actually changed + assert self.db.get_local_karma(commands.user, commands.peer_id) == initial_karma + 3 + + @ordered + def test_local_karma_sorting(self) -> NoReturn: + """Test local karma user sorting""" + # Create multiple users with different local karma + user1 = self.db.get_or_create_user(10, None) + user2 = self.db.get_or_create_user(20, None) + user3 = self.db.get_or_create_user(30, None) + + chat_id = 2000000001 + + self.db.set_local_karma(user1, chat_id, 10) + self.db.set_local_karma(user2, chat_id, 5) + self.db.set_local_karma(user3, chat_id, 15) + + # Mock VK instance for testing + class MockVk: + def get_members_ids(self, peer_id): + return [10, 20, 30] + + users = DataBuilder.get_users_sorted_by_local_karma(MockVk(), self.db, chat_id, chat_id) + + # Should be sorted by local karma descending + assert users[0]["local_karma"] == 15 # user3 + assert users[1]["local_karma"] == 10 # user1 + assert users[2]["local_karma"] == 5 # user2 + + if __name__ == '__main__': db = BetterBotBaseDataService("test_db") defaultTestLoader.sortTestMethodsUsing = compare