From f0649558e8231141d9c465b5006c107ce2f77ffe Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 23:47:56 -0700 Subject: [PATCH] refactor(commands): direct help call only when the method also accepts the message Space bunny on d60e7cf1: operators may install their own command files inside modules/commands or modules/commands/alternatives (documented, and preserved across upgrades), so a class under modules. is not necessarily bundled. Such a command written as get_help_text(self) was treated as bundled, called with the message and raised, so help returned nothing where dev returned its text. help_text_for now calls once with the message only when the method comes from a class under modules. and its signature binds the message; otherwise, including when the signature can't be read, it uses dev's call-then-retry. New test: an operator command in the modules.commands namespace with get_help_text(self) gets its help. --- modules/commands/base_command.py | 22 +++++++++++++++++----- tests/test_help_command.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) 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()