diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index a308ad7..ecee966 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -4,6 +4,7 @@ Base command class for all MeshCore Bot commands Provides common functionality and interface for command implementations """ +import inspect import re from abc import ABC, abstractmethod from collections.abc import Callable, Iterator @@ -71,17 +72,28 @@ def _defined_by_bundled_class(command: Any, method: Any) -> bool: return False +def _accepts_message(method: Any, message: Any) -> bool: + """Whether ``method(message)`` binds, per its signature (False when that can't be read).""" + try: + inspect.signature(method).bind(message) + except (TypeError, ValueError): + return False + return True + + def help_text_for(command: Any, message: Any) -> str: """``command.get_help_text(message)``, retrying without the message for local plugins. Every bundled command's get_help_text takes the message, so when the - method comes from a bundled class it is called once and an error from its - body propagates. Anything else (a local plugin or subclass override, which - may implement ``get_help_text(self)``, or any other callable) keeps the old - contract: a TypeError from the message call is retried without the message. + method comes from a class under ``modules.`` and its signature accepts the + message, it is called once and an error from its body propagates. Anything + else keeps the old contract, where a TypeError from the message call is + retried without the message: local plugins and overrides, operator-installed + commands inside modules/commands written as ``get_help_text(self)``, and + any other callable. """ get_help_text = command.get_help_text - if _defined_by_bundled_class(command, get_help_text): + if _defined_by_bundled_class(command, get_help_text) and _accepts_message(get_help_text, message): return get_help_text(message) try: return get_help_text(message) diff --git a/tests/test_help_command.py b/tests/test_help_command.py index 6fa2d57..cc5a1ec 100644 --- a/tests/test_help_command.py +++ b/tests/test_help_command.py @@ -252,6 +252,25 @@ class TestGetSpecificHelp: cmd = HelpCommand(bot) cmd.get_specific_help("ping", Mock()) # must not raise + def test_operator_command_inside_modules_without_message_param_gets_help(self): + """An installed-only command file in modules/commands may use get_help_text(self).""" + from modules.commands.base_command import BaseCommand + + class OperatorWeatherCommand(BaseCommand): + name = "operatorweather" + + def get_help_text(self): + return "operator help" + + async def execute(self, message): + return True + + OperatorWeatherCommand.__module__ = "modules.commands.operatorweather" + bot = _make_bot() + bot.command_manager.commands = {"operatorweather": object.__new__(OperatorWeatherCommand)} + cmd = HelpCommand(bot) + cmd.get_specific_help("operatorweather", Mock()) # must not raise + def test_local_plugin_help_keeps_the_typeerror_retry(self): """A local get_help_text(message=None) that raises TypeError is retried without it.""" bot = _make_bot()