diff --git a/modules/command_manager.py b/modules/command_manager.py index c08aeb8..2233708 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -1470,6 +1470,28 @@ class CommandManager: return False return True + def _find_help_command(self, lookup_name: str) -> Any | None: + """Resolve an exact command name or alias.""" + normalized_name = lookup_name.lower() + command = self.commands.get(normalized_name) or self.commands.get(lookup_name) + if command: + return command + + if hasattr(self, 'plugin_loader') and hasattr(self.plugin_loader, 'keyword_mappings'): + mapped_name = self.plugin_loader.keyword_mappings.get(normalized_name) + if mapped_name: + command = self.commands.get(mapped_name) + if command: + return command + + for cmd_instance in self.commands.values(): + if ( + hasattr(cmd_instance, 'keywords') + and normalized_name in [keyword.lower() for keyword in cmd_instance.keywords] + ): + return cmd_instance + return None + def get_help_for_command(self, command_name: str, message: MeshMessage | None = None) -> str: """Get help text for a specific command (LoRa-friendly compact format). @@ -1486,60 +1508,18 @@ class CommandManager: return self.get_general_help(message) requested_name = command_name.strip() - # Help may include subcommand arguments (for example, ``help net - # create``). Resolve the plugin from the first token, then pass the - # unchanged MeshMessage to get_help_text() so the plugin can interpret - # the remaining text in its own command-specific way. - lookup_name = requested_name.split(maxsplit=1)[0] if requested_name else "" - normalized_name = lookup_name.lower() - - # First, try to find a command by exact name - command = self.commands.get(normalized_name) or self.commands.get(lookup_name) + command = self._find_help_command(requested_name) + if not command and requested_name: + command = self._find_help_command(requested_name.split(maxsplit=1)[0]) if command: - # Try to pass message context to get_help_text if supported try: help_text = command.get_help_text(message) except TypeError: - # Fallback for commands that don't accept message parameter help_text = command.get_help_text() - # Use translator if available if hasattr(self.bot, 'translator'): return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text) return f"Help {command_name}: {help_text}" - # Next, consult plugin_loader keyword mappings (if available) - mapped_name: str | None = None - if hasattr(self, 'plugin_loader') and hasattr(self.plugin_loader, 'keyword_mappings'): - mapped_name = self.plugin_loader.keyword_mappings.get(normalized_name) - if mapped_name: - command = self.commands.get(mapped_name) - if command: - try: - help_text = command.get_help_text(message) - except TypeError: - help_text = command.get_help_text() - if hasattr(self.bot, 'translator'): - return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text) - return f"Help {command_name}: {help_text}" - - # If still not found, search through all commands and their keywords - for _cmd_name, cmd_instance in self.commands.items(): - # Check if the requested command name matches any of this command's keywords - if ( - hasattr(cmd_instance, 'keywords') - and normalized_name in [k.lower() for k in cmd_instance.keywords] - ): - # Try to pass message context to get_help_text if supported - try: - help_text = cmd_instance.get_help_text(message) - except TypeError: - # Fallback for commands that don't accept message parameter - help_text = cmd_instance.get_help_text() - # Use translator if available - if hasattr(self.bot, 'translator'): - return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text) - return f"Help {command_name}: {help_text}" - # If still not found, return unknown command message with helpful suggestion # Use the help command's method to get popular commands (only primary names, no aliases) available_str = "" diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index e43a010..c2dda43 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -94,31 +94,36 @@ class HelpCommand(BaseCommand): str: The formatted help text for the specific command. """ requested_name = command_name.strip() - lookup_name = requested_name.split(maxsplit=1)[0] if requested_name else "" - normalized_name = lookup_name.lower() + command = None + candidates = [requested_name] + if requested_name: + base_name = requested_name.split(maxsplit=1)[0] + if base_name != requested_name: + candidates.append(base_name) - # Get the command instance by direct name first - command = ( - self.bot.command_manager.commands.get(normalized_name) - or self.bot.command_manager.commands.get(lookup_name) - ) - - # Then through plugin keyword mappings (if available) - if not command and hasattr(self.bot.command_manager, 'plugin_loader'): - mappings = getattr(self.bot.command_manager.plugin_loader, 'keyword_mappings', {}) - mapped_name = mappings.get(normalized_name) - if mapped_name: - command = self.bot.command_manager.commands.get(mapped_name) - - # Final fallback: resolve through runtime command keywords - if not command: - for cmd_instance in self.bot.command_manager.commands.values(): - if ( - hasattr(cmd_instance, 'keywords') - and normalized_name in [k.lower() for k in cmd_instance.keywords] - ): - command = cmd_instance - break + mappings = getattr(self.bot.command_manager.plugin_loader, 'keyword_mappings', {}) + for lookup_name in candidates: + normalized_name = lookup_name.lower() + command = ( + self.bot.command_manager.commands.get(normalized_name) + or self.bot.command_manager.commands.get(lookup_name) + ) + if not command: + mapped_name = mappings.get(normalized_name) + if mapped_name: + command = self.bot.command_manager.commands.get(mapped_name) + if not command: + command = next( + ( + cmd_instance + for cmd_instance in self.bot.command_manager.commands.values() + if hasattr(cmd_instance, 'keywords') + and normalized_name in [keyword.lower() for keyword in cmd_instance.keywords] + ), + None, + ) + if command: + break if command: # Pass message context to get_help_text if the method supports it @@ -314,4 +319,3 @@ class HelpCommand(BaseCommand): return ', '.join(result) + suffix break return ', '.join(result) - diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py index 6a1ea00..b27d20a 100644 --- a/tests/test_command_manager.py +++ b/tests/test_command_manager.py @@ -362,6 +362,24 @@ class TestGetHelpForCommand: mock_cmd.get_help_text.assert_called_once_with(message) assert message.content == "help net create" + def test_multiword_alias_is_checked_before_subcommand_fallback(self, cm_bot): + dadjoke_cmd = MagicMock() + dadjoke_cmd.keywords = ["dadjoke", "dad joke"] + dadjoke_cmd.get_help_text = Mock(return_value="Dad joke help") + unrelated_cmd = MagicMock() + unrelated_cmd.keywords = ["dad"] + unrelated_cmd.get_help_text = Mock(return_value="Wrong help") + manager = make_manager( + cm_bot, + commands={"dadjoke": dadjoke_cmd, "dad": unrelated_cmd}, + ) + + result = manager.get_help_for_command("dad joke") + + assert "Dad joke help" in result + dadjoke_cmd.get_help_text.assert_called_once() + unrelated_cmd.get_help_text.assert_not_called() + class TestInternetStatusCache: """Tests for InternetStatusCache.""" diff --git a/tests/test_help_command.py b/tests/test_help_command.py index 3a77acb..e37dd9b 100644 --- a/tests/test_help_command.py +++ b/tests/test_help_command.py @@ -253,6 +253,25 @@ class TestGetSpecificHelp: result = cmd.get_specific_help("sched") assert isinstance(result, str) + def test_multiword_alias_is_checked_before_subcommand_fallback(self): + bot = _make_bot() + dadjoke_cmd = MagicMock() + dadjoke_cmd.get_help_text = Mock(return_value="Dad joke help") + dadjoke_cmd.keywords = ["dadjoke", "dad joke"] + unrelated_cmd = MagicMock() + unrelated_cmd.get_help_text = Mock(return_value="Wrong help") + unrelated_cmd.keywords = ["dad"] + bot.command_manager.commands = { + "dadjoke": dadjoke_cmd, + "dad": unrelated_cmd, + } + cmd = HelpCommand(bot) + + cmd.get_specific_help("dad joke") + + dadjoke_cmd.get_help_text.assert_called_once() + unrelated_cmd.get_help_text.assert_not_called() + def test_no_get_help_text_attribute(self): """Command without get_help_text returns no_help key.""" bot = _make_bot()