From c61d628a1f306e7a288287f0f34252d4690a899f Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Fri, 7 Aug 2026 21:10:16 -0400 Subject: [PATCH 1/4] fix: List only commands that are enabled in cmd response Signed-off-by: Gerard Hickey --- modules/commands/base_command.py | 7 +++++++ modules/commands/cmd_command.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 5dd10fe..b753693 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -543,6 +543,13 @@ class BaseCommand(ABC): # Check if channel matches allowed list return message_channel_normalized in allowed_normalized + def is_enabled(self) -> bool: + """Return whether this command is enabled per config.""" + for attr_name, attr_val in vars(self).items(): + if attr_name.endswith('_enabled') and isinstance(attr_val, bool): + return attr_val + return True + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. diff --git a/modules/commands/cmd_command.py b/modules/commands/cmd_command.py index b45afe5..c6eec0a 100644 --- a/modules/commands/cmd_command.py +++ b/modules/commands/cmd_command.py @@ -97,6 +97,8 @@ class CmdCommand(BaseCommand): for cmd_name, cmd_instance in self.bot.command_manager.commands.items(): # Skip system commands without keywords (like greeter) if hasattr(cmd_instance, 'keywords') and cmd_instance.keywords: + if hasattr(cmd_instance, 'is_enabled') and not cmd_instance.is_enabled(): + continue if not self._is_command_valid_for_channel(cmd_name, cmd_instance, message): continue all_commands.append(cmd_name) From 09e212e4d1c684f94c10560c483e2cb2f8979ae0 Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Sun, 9 Aug 2026 22:47:28 -0400 Subject: [PATCH 2/4] Add test to verify disabled cmds are not listed in _get_commands_list() Signed-off-by: Gerard Hickey --- tests/commands/test_cmd_command.py | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/commands/test_cmd_command.py b/tests/commands/test_cmd_command.py index 2a4db95..1ef1237 100644 --- a/tests/commands/test_cmd_command.py +++ b/tests/commands/test_cmd_command.py @@ -4,7 +4,15 @@ import pytest from modules.commands.cmd_command import CmdCommand from tests.conftest import mock_message +from dataclasses import dataclass +@dataclass +class MockCmdObject: + enabled: bool + keywords: str + + def is_enabled(self): + return self.enabled class TestCmdCommand: """Tests for CmdCommand.""" @@ -24,6 +32,32 @@ class TestCmdCommand: msg = mock_message(content="cmd", is_dm=True) assert cmd.can_execute(msg) is False + @pytest.mark.asyncio + async def test_is_not_listed_when_sports_cmd_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Cmd_Command") + command_mock_bot.config.set("Cmd_Command", "enabled", "true") + command_mock_bot.command_manager.keywords = {} + command_mock_bot.command_manager.commands = { + 'test': MockCmdObject(enabled=True, keywords=['test']), + 'ping': MockCmdObject(enabled=True, keywords=['ping']), + 'wx': MockCmdObject(enabled=True, keywords=['wx']), + 'sports': MockCmdObject(enabled=False, keywords=['sports']), + 'trace': MockCmdObject(enabled=True, keywords=['trace']), + 'alert': MockCmdObject(enabled=True, keywords=['alert'])} + + cmd = CmdCommand(command_mock_bot) + msg = mock_message(content="cmd", is_dm=True) + result = await cmd.execute(msg) + call_args = command_mock_bot.command_manager.send_response.call_args + + assert result is True + assert 'test' in call_args[0][1] + assert 'ping' in call_args[0][1] + assert 'wx' in call_args[0][1] + assert 'sports' not in call_args[0][1] + assert 'trace' in call_args[0][1] + assert 'alert' in call_args[0][1] + @pytest.mark.asyncio async def test_execute_returns_command_list(self, command_mock_bot): command_mock_bot.config.add_section("Cmd_Command") From 91eaf1c87e9fd7195f2b3a73c978dc24109687ef Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Sun, 9 Aug 2026 22:50:41 -0400 Subject: [PATCH 3/4] Update CHANGELOG.md with changes to `cmd` Signed-off-by: Gerard Hickey --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51af35f..88b917c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format loosely foll [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to semantic versioning. +## [Unreleased] + +### Fixed + +- `cmd` no longer lists commands that are disabled in config + ## [1.0.0] — 2026-08-07 v1.0.0 marks the first stable release. It adds zero-hop neighbor discovery, a From 84f6130573de3be2e26d118eced7c4b4c4e6962c Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Mon, 10 Aug 2026 09:56:05 -0400 Subject: [PATCH 4/4] Refactor to use config file settings Signed-off-by: Gerard Hickey --- modules/commands/base_command.py | 7 ----- modules/commands/cmd_command.py | 3 ++- tests/commands/test_cmd_command.py | 41 +++++++++++++++++------------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index b753693..5dd10fe 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -543,13 +543,6 @@ class BaseCommand(ABC): # Check if channel matches allowed list return message_channel_normalized in allowed_normalized - def is_enabled(self) -> bool: - """Return whether this command is enabled per config.""" - for attr_name, attr_val in vars(self).items(): - if attr_name.endswith('_enabled') and isinstance(attr_val, bool): - return attr_val - return True - def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. diff --git a/modules/commands/cmd_command.py b/modules/commands/cmd_command.py index c6eec0a..6abc217 100644 --- a/modules/commands/cmd_command.py +++ b/modules/commands/cmd_command.py @@ -97,7 +97,8 @@ class CmdCommand(BaseCommand): for cmd_name, cmd_instance in self.bot.command_manager.commands.items(): # Skip system commands without keywords (like greeter) if hasattr(cmd_instance, 'keywords') and cmd_instance.keywords: - if hasattr(cmd_instance, 'is_enabled') and not cmd_instance.is_enabled(): + section_name = cmd_instance._derive_config_section_name() + if cmd_instance.get_config_value(section_name, "enabled", "false", bool) == "false": continue if not self._is_command_valid_for_channel(cmd_name, cmd_instance, message): continue diff --git a/tests/commands/test_cmd_command.py b/tests/commands/test_cmd_command.py index 1ef1237..c68b6f0 100644 --- a/tests/commands/test_cmd_command.py +++ b/tests/commands/test_cmd_command.py @@ -2,17 +2,14 @@ import pytest +from modules.commands.alert_command import AlertCommand from modules.commands.cmd_command import CmdCommand +from modules.commands.ping_command import PingCommand +from modules.commands.sports_command import SportsCommand +from modules.commands.trace_command import TraceCommand +from modules.commands.wx_command import WxCommand from tests.conftest import mock_message -from dataclasses import dataclass -@dataclass -class MockCmdObject: - enabled: bool - keywords: str - - def is_enabled(self): - return self.enabled class TestCmdCommand: """Tests for CmdCommand.""" @@ -36,14 +33,25 @@ class TestCmdCommand: async def test_is_not_listed_when_sports_cmd_disabled(self, command_mock_bot): command_mock_bot.config.add_section("Cmd_Command") command_mock_bot.config.set("Cmd_Command", "enabled", "true") + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "true") + command_mock_bot.config.add_section("Wx_Command") + command_mock_bot.config.set("Wx_Command", "enabled", "true") + command_mock_bot.config.add_section("Sports_Command") + command_mock_bot.config.set("Sports_Command", "enabled", "false") + command_mock_bot.config.add_section("Trace_Command") + command_mock_bot.config.set("Trace_Command", "enabled", "true") + command_mock_bot.config.add_section("Alert_Command") + command_mock_bot.config.set("Alert_Command", "enabled", "true") + command_mock_bot.command_manager.keywords = {} command_mock_bot.command_manager.commands = { - 'test': MockCmdObject(enabled=True, keywords=['test']), - 'ping': MockCmdObject(enabled=True, keywords=['ping']), - 'wx': MockCmdObject(enabled=True, keywords=['wx']), - 'sports': MockCmdObject(enabled=False, keywords=['sports']), - 'trace': MockCmdObject(enabled=True, keywords=['trace']), - 'alert': MockCmdObject(enabled=True, keywords=['alert'])} + 'ping': PingCommand(command_mock_bot), + 'wx': WxCommand(command_mock_bot), + 'sports': SportsCommand(command_mock_bot), + 'trace': TraceCommand(command_mock_bot), + 'alert': AlertCommand(command_mock_bot) + } cmd = CmdCommand(command_mock_bot) msg = mock_message(content="cmd", is_dm=True) @@ -51,7 +59,6 @@ class TestCmdCommand: call_args = command_mock_bot.command_manager.send_response.call_args assert result is True - assert 'test' in call_args[0][1] assert 'ping' in call_args[0][1] assert 'wx' in call_args[0][1] assert 'sports' not in call_args[0][1] @@ -119,8 +126,8 @@ class TestCmdCommand: commands = {} for i in range(25): name = f"longcommandname{i:02d}" - mock_cmd = type("MockCmd", (), {"keywords": [name]})() - commands[name] = mock_cmd + commands[name] = CmdCommand(command_mock_bot) + command_mock_bot.config.add_section(name.title() + "_Command") command_mock_bot.command_manager.commands = commands cmd = CmdCommand(command_mock_bot) # "Available commands: " = 20 chars; "longcommandnameNN" = 17 chars; ", " = 2 chars