From af05c2ef7f5575df3cf7911f94247d6e1e7c87d9 Mon Sep 17 00:00:00 2001 From: Gerard Hickey Date: Sun, 20 Sep 2026 01:33:54 -0400 Subject: [PATCH] feat(website): Include local commands in generated HTML (#287) * feat(website): Include local commands when generating website Signed-off-by: Gerard Hickey * feat(website): Disabled commands are excluded from generated HTML Signed-off-by: Gerard Hickey * Updated CHANGELOG.md Signed-off-by: Gerard Hickey * fix(website): resolve enablement through the bot's own config helpers The generated page is meant to list what the bot actually answers, so it has to read the config the same way the bot does. Three places where it did not: The local `config.ini` overlay was never read. `read_config` parsed only the base file, while the bot overlays `/config.ini` on top of it, and that overlay is exactly where the web viewer saves a local plugin's settings. A local command disabled from the settings page still showed up on the site. `is_command_enabled` derived the section name and the legacy `enabled` spellings by hand, duplicating `BaseCommand._derive_config_section_name` and guessing that a legacy key lives in the command's own section. Half of them do not: `[Jokes] joke_enabled` disables `Joke_Command`. It now calls `command_section_name` and `read_enabled`, which share the alias table with the runtime and the settings UI. That table was missing the same-section `[Joke_Command] joke_enabled` and `[DadJoke_Command] dadjoke_enabled` spellings, which both commands accept through their own fallback. The settings page read only the `[Jokes]` form, so a bot disabled the same-section way displayed as enabled. Also fold the duplicated local-commands-dir resolution into `resolve_local_commands_dir`, take `bot_root` as a `MinimalBot` argument instead of assigning it post-construction twice, and restore the `hidden` attribute check that `generate_samples` lost when it moved to `filter_commands`. --------- Signed-off-by: Gerard Hickey Co-authored-by: agessaman --- CHANGELOG.md | 13 + docs/command-reference-website.md | 10 + generate_website.py | 96 +++++-- modules/config_schema.py | 4 +- ...est_generate_website_local_and_disabled.py | 237 ++++++++++++++++++ 5 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 tests/test_generate_website_local_and_disabled.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bca10..345fa1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ semantic versioning. card so a user can add the bot and DM it without waiting for an advert. Useful for bots that do not advertise. Enabled by default; disable with `[Contact_Command] enabled = false`. +- `generate_website.py` now lists the local commands installed under + `[Bot] local_dir_path` and omits commands disabled in the configuration file + (#287). It also reads the `/config.ini` overlay the way the + bot does, which is where the settings UI saves a local plugin's state, and it + resolves a command's section and legacy `enabled` aliases through the bot's + own helpers, so `[Jokes] joke_enabled = false` is honored rather than silently + ignored. ### Changed @@ -38,6 +45,12 @@ semantic versioning. ### Fixed +- `[Joke_Command] joke_enabled` and `[DadJoke_Command] dadjoke_enabled` are now + listed in the shared legacy-alias table. Both commands accepted that spelling + at runtime through their own fallback, but the settings UI read only the + `[Jokes]` spelling, so a bot disabled the same-section way showed as enabled + on the plugin settings page. + - A region-scoped channel message now restores global flood even when `set_flood_scope` raises. The restore only ran in the `finally` around the send, so a set that raised left the device pinned to that region and every diff --git a/docs/command-reference-website.md b/docs/command-reference-website.md index 468b4fe..46cec5b 100644 --- a/docs/command-reference-website.md +++ b/docs/command-reference-website.md @@ -10,6 +10,16 @@ python generate_website.py [config.ini] This reads your config (default: `config.ini`), loads your commands and channels, and writes **`website/index.html`** in the same directory as the config file. Upload that file (and the directory if you use assets) to your web host. +## Which commands get listed + +The page lists what your bot actually answers: + +- **Local commands are included.** Any command installed under `[Bot] local_dir_path` (default `local/commands`) appears alongside the built-in ones. +- **Disabled commands are omitted.** A command turned off with `enabled = false` in its `[*_Command]` section is left out, including the legacy spellings such as `[Jokes] joke_enabled`. +- **The local config overlay is read.** Settings saved from the web viewer's plugin settings page land in `/config.ini`, and the generator reads that on top of your base config just as the bot does. + +Admin-only, hidden, and keyword-less commands are never listed. + ## Choose a style The `--style` option selects a theme (colors, typography, layout). Default is `default` (modern dark). To see all themes: diff --git a/generate_website.py b/generate_website.py index 6ecec46..72ad6e0 100755 --- a/generate_website.py +++ b/generate_website.py @@ -24,6 +24,7 @@ try: from modules.config_validation import strip_optional_quotes from modules.db_manager import DBManager from modules.plugin_loader import PluginLoader + from modules.settings_schema import command_section_name, read_enabled from modules.utils import resolve_path except ImportError as e: print("Error: Missing required dependencies.") @@ -39,10 +40,11 @@ except ImportError as e: class MinimalBot: """Minimal bot mock for plugin loading without full bot initialization""" - def __init__(self, config, logger, db_manager=None): + def __init__(self, config, logger, db_manager=None, bot_root=None): self.config = config self.logger = logger self.db_manager = db_manager + self.bot_root = bot_root # Dummy translator class DummyTranslator: @@ -920,11 +922,25 @@ def setup_logging(): def read_config(config_file: str = "config.ini") -> configparser.ConfigParser: - """Read and parse config.ini file""" + """Read config.ini, then overlay `/config.ini` if present. + + Mirrors `Bot._read_config_snapshot` in modules/core.py. The overlay is where + the settings UI writes a local plugin's settings, so without it the + generated page would advertise commands the running bot refuses. + """ config = configparser.ConfigParser() if not os.path.exists(config_file): raise FileNotFoundError(f"Config file not found: {config_file}") config.read(config_file) + + bot_root = os.path.dirname(os.path.abspath(config_file)) + local_config = os.path.join( + resolve_path(config.get('Bot', 'local_dir_path', fallback='local'), bot_root), + 'config.ini', + ) + if os.path.isfile(local_config): + config.read(local_config) + return config @@ -1144,8 +1160,49 @@ def get_command_popularity(db_path: Optional[str], commands: dict[str, Any]) -> return popularity -def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict[str, Any]: - """Filter out admin and hidden commands""" +def resolve_local_commands_dir( + config: configparser.ConfigParser, bot_root: str, logger: logging.Logger +) -> Optional[str]: + """Resolve the local commands directory, or None when it does not exist. + + PluginLoader treats None as "no local commands", so a bot without a local + directory loads only the built-in plugins. + """ + local_dir_path = config.get('Bot', 'local_dir_path', fallback='local') + local_commands_dir = resolve_path(os.path.join(local_dir_path, 'commands'), bot_root) + + if not os.path.isdir(local_commands_dir): + logger.info(f"Local commands directory not found: {local_commands_dir}") + return None + + logger.info(f"Using local commands directory: {local_commands_dir}") + return local_commands_dir + + +def is_command_enabled(cmd_instance: Any, config: configparser.ConfigParser) -> bool: + """Report whether a command is enabled in the config. + + Defers to the same section derivation and legacy `enabled` aliases the bot + uses at runtime, so a command the bot will not answer is not advertised on + the generated page. Commands with no name and commands with no explicit + setting are treated as enabled. + + Args: + cmd_instance: Command instance to check + config: Config parser instance + + Returns: + bool: True if command is enabled, False otherwise + """ + cmd_name = getattr(cmd_instance, 'name', None) + if not cmd_name: + return True + + return read_enabled(config, command_section_name(cmd_name), default=True) + + +def filter_commands(commands: dict[str, Any], admin_commands: list[str], config: configparser.ConfigParser) -> dict[str, Any]: + """Filter out admin, hidden, and disabled commands""" filtered = {} # Categories to exclude from public reference @@ -1156,6 +1213,10 @@ def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict if hasattr(cmd_instance, 'category') and cmd_instance.category in excluded_categories: continue + # Skip commands that mark themselves hidden + if getattr(cmd_instance, 'hidden', False): + continue + # Get primary command name primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name @@ -1171,6 +1232,10 @@ def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict if hasattr(cmd_instance, 'keywords') and not cmd_instance.keywords: continue + # Skip commands that are disabled in config + if not is_command_enabled(cmd_instance, config): + continue + filtered[cmd_name] = cmd_instance return filtered @@ -2558,7 +2623,7 @@ def generate_samples(config_file, link_css: Optional[str] = None, custom_css: Op channels_data = load_channels_from_config(config) # Setup minimal bot for plugin loading - minimal_bot = MinimalBot(config, logger) + minimal_bot = MinimalBot(config, logger, bot_root=bot_root) # Initialize database manager if database exists db_path = get_database_path(config, bot_root) @@ -2571,15 +2636,14 @@ def generate_samples(config_file, link_css: Optional[str] = None, custom_css: Op else: minimal_bot.db_manager = None + local_commands_dir = resolve_local_commands_dir(config, bot_root, logger) + # Load plugins - plugin_loader = PluginLoader(minimal_bot) + plugin_loader = PluginLoader(minimal_bot, local_commands_dir=local_commands_dir) commands = plugin_loader.load_all_plugins() - # Filter out admin and hidden commands - public_commands = { - name: cmd for name, cmd in commands.items() - if name not in admin_commands and not getattr(cmd, 'hidden', False) - } + # Filter out admin, hidden, and disabled commands + public_commands = filter_commands(commands, admin_commands, config) public_commands.update(get_randomline_commands(config)) # Sort commands @@ -2817,7 +2881,7 @@ def main(): logger.info(f"Monitor channels: {monitor_channels}") # Setup minimal bot for plugin loading - minimal_bot = MinimalBot(config, logger) + minimal_bot = MinimalBot(config, logger, bot_root=bot_root) # Initialize database manager if database exists db_path = get_database_path(config, bot_root) @@ -2832,14 +2896,16 @@ def main(): logger.info("No database found, using default command ordering") minimal_bot.db_manager = None + local_commands_dir = resolve_local_commands_dir(config, bot_root, logger) + # Load plugins logger.info("Loading command plugins...") - plugin_loader = PluginLoader(minimal_bot) + plugin_loader = PluginLoader(minimal_bot, local_commands_dir=local_commands_dir) commands = plugin_loader.load_all_plugins() logger.info(f"Loaded {len(commands)} commands") - # Filter out admin and hidden commands - filtered_commands = filter_commands(commands, admin_commands) + # Filter out admin, hidden, and disabled commands + filtered_commands = filter_commands(commands, admin_commands, config) filtered_commands.update(get_randomline_commands(config)) logger.info(f"Filtered to {len(filtered_commands)} public commands") diff --git a/modules/config_schema.py b/modules/config_schema.py index 3d36cf5..2f5ac9b 100644 --- a/modules/config_schema.py +++ b/modules/config_schema.py @@ -50,8 +50,8 @@ LEGACY_ENABLED_KEY_RE = re.compile(r"^[a-z]+_enabled$") # behavior and the UI can never disagree. # Maps canonical section -> ordered ((legacy_section, legacy_key), ...). LEGACY_ENABLED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = { - "Joke_Command": (("Jokes", "joke_enabled"),), - "DadJoke_Command": (("Jokes", "dadjoke_enabled"),), + "Joke_Command": (("Joke_Command", "joke_enabled"), ("Jokes", "joke_enabled")), + "DadJoke_Command": (("DadJoke_Command", "dadjoke_enabled"), ("Jokes", "dadjoke_enabled")), "Stats_Command": (("Stats_Command", "stats_enabled"), ("Stats", "stats_enabled")), "Sports_Command": (("Sports_Command", "sports_enabled"), ("Sports", "sports_enabled")), "Hacker_Command": (("Hacker_Command", "hacker_enabled"), ("Hacker", "hacker_enabled")), diff --git a/tests/test_generate_website_local_and_disabled.py b/tests/test_generate_website_local_and_disabled.py new file mode 100644 index 0000000..68bf91f --- /dev/null +++ b/tests/test_generate_website_local_and_disabled.py @@ -0,0 +1,237 @@ +"""Tests for local command discovery and disabled-command filtering in generate_website.py""" + +import configparser +import logging +import os +from types import SimpleNamespace + +import pytest + +from generate_website import ( + filter_commands, + is_command_enabled, + read_config, + resolve_local_commands_dir, +) + + +def _config(text: str) -> configparser.ConfigParser: + config = configparser.ConfigParser() + config.read_string(text) + return config + + +def _command(name: str, **attrs): + defaults = { + 'name': name, + 'keywords': [name], + 'category': 'general', + 'requires_admin_access': lambda: False, + } + defaults.update(attrs) + return SimpleNamespace(**defaults) + + +# --------------------------------------------------------------------------- +# is_command_enabled +# --------------------------------------------------------------------------- + +def test_no_config_section_means_enabled(): + assert is_command_enabled(_command('ping'), _config('')) is True + + +def test_section_without_enabled_key_means_enabled(): + config = _config('[Ping_Command]\ncooldown_seconds = 5\n') + + assert is_command_enabled(_command('ping'), config) is True + + +@pytest.mark.parametrize('raw', ['false', 'no', 'off', '0']) +def test_canonical_enabled_key_disables(raw): + config = _config(f'[Ping_Command]\nenabled = {raw}\n') + + assert is_command_enabled(_command('ping'), config) is False + + +@pytest.mark.parametrize('raw', ['true', 'yes', 'on', '1']) +def test_canonical_enabled_key_enables(raw): + config = _config(f'[Ping_Command]\nenabled = {raw}\n') + + assert is_command_enabled(_command('ping'), config) is True + + +def test_camel_case_name_resolves_its_section(): + config = _config('[DadJoke_Command]\nenabled = false\n') + + assert is_command_enabled(_command('dadjoke'), config) is False + # The naive "dadjoke".title() spelling must not be what gets read. + assert not config.has_section('Dadjoke_Command') + + +def test_legacy_alias_in_another_section_disables(): + """[Jokes] joke_enabled lives in a different section than Joke_Command.""" + config = _config('[Jokes]\njoke_enabled = false\n') + + assert is_command_enabled(_command('joke'), config) is False + + +@pytest.mark.parametrize('name,section,key', [ + ('sports', 'Sports_Command', 'sports_enabled'), + ('stats', 'Stats_Command', 'stats_enabled'), + ('hacker', 'Hacker_Command', 'hacker_enabled'), + ('alert', 'Alert_Command', 'alert_enabled'), + # joke/dadjoke accept the same-section spelling too, via their own + # __init__ fallback — see modules/commands/joke_command.py. + ('joke', 'Joke_Command', 'joke_enabled'), + ('dadjoke', 'DadJoke_Command', 'dadjoke_enabled'), +]) +def test_legacy_alias_in_same_section_disables(name, section, key): + config = _config(f'[{section}]\n{key} = false\n') + + assert is_command_enabled(_command(name), config) is False + + +def test_canonical_key_wins_over_legacy_alias(): + config = _config('[Joke_Command]\nenabled = true\n[Jokes]\njoke_enabled = false\n') + + assert is_command_enabled(_command('joke'), config) is True + + +def test_unparseable_enabled_value_falls_back_to_enabled(): + config = _config('[Ping_Command]\nenabled = maybe\n') + + assert is_command_enabled(_command('ping'), config) is True + + +def test_command_without_a_name_is_enabled(): + assert is_command_enabled(SimpleNamespace(), _config('')) is True + + +# --------------------------------------------------------------------------- +# filter_commands +# --------------------------------------------------------------------------- + +def test_filter_commands_drops_disabled_commands(): + commands = {'ping': _command('ping'), 'wx': _command('wx')} + config = _config('[Wx_Command]\nenabled = false\n') + + assert set(filter_commands(commands, [], config)) == {'ping'} + + +def test_filter_commands_drops_disabled_local_command(): + """A local command is filtered by the same rules as a built-in one.""" + commands = {'sitrep': _command('sitrep')} + config = _config('[Sitrep_Command]\nenabled = false\n') + + assert filter_commands(commands, [], config) == {} + + +def test_filter_commands_keeps_enabled_local_command(): + commands = {'sitrep': _command('sitrep')} + + assert set(filter_commands(commands, [], _config(''))) == {'sitrep'} + + +def test_filter_commands_still_drops_admin_and_hidden(): + commands = { + 'ping': _command('ping'), + 'reboot': _command('reboot'), + 'secret': _command('secret', category='hidden'), + 'sudo': _command('sudo', requires_admin_access=lambda: True), + 'silent': _command('silent', keywords=[]), + 'quiet': _command('quiet', hidden=True), + } + + assert set(filter_commands(commands, ['reboot'], _config(''))) == {'ping'} + + +# --------------------------------------------------------------------------- +# resolve_local_commands_dir +# --------------------------------------------------------------------------- + +def test_resolve_local_commands_dir_returns_none_when_missing(tmp_path): + logger = logging.getLogger('test') + + assert resolve_local_commands_dir(_config(''), str(tmp_path), logger) is None + + +def test_resolve_local_commands_dir_finds_default_location(tmp_path): + expected = tmp_path / 'local' / 'commands' + expected.mkdir(parents=True) + logger = logging.getLogger('test') + + result = resolve_local_commands_dir(_config(''), str(tmp_path), logger) + + assert result is not None + assert os.path.realpath(result) == os.path.realpath(str(expected)) + + +def test_resolve_local_commands_dir_honors_configured_local_dir(tmp_path): + expected = tmp_path / 'custom' / 'commands' + expected.mkdir(parents=True) + logger = logging.getLogger('test') + + result = resolve_local_commands_dir( + _config('[Bot]\nlocal_dir_path = custom\n'), str(tmp_path), logger + ) + + assert result is not None + assert os.path.realpath(result) == os.path.realpath(str(expected)) + + +def test_resolve_local_commands_dir_rejects_a_file(tmp_path): + """A file named `commands` is not a plugin directory.""" + (tmp_path / 'local').mkdir() + (tmp_path / 'local' / 'commands').write_text('not a directory') + logger = logging.getLogger('test') + + assert resolve_local_commands_dir(_config(''), str(tmp_path), logger) is None + + +# --------------------------------------------------------------------------- +# read_config local overlay +# --------------------------------------------------------------------------- + +def _write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + +def test_read_config_overlays_local_config(tmp_path): + """The settings UI writes a local plugin's state to local/config.ini.""" + base = _write(tmp_path / 'config.ini', '[Bot]\nbot_name = TestBot\n') + _write(tmp_path / 'local' / 'config.ini', '[Sitrep_Command]\nenabled = false\n') + + config = read_config(str(base)) + + assert config.get('Bot', 'bot_name') == 'TestBot' + assert is_command_enabled(_command('sitrep'), config) is False + + +def test_local_overlay_wins_over_base(tmp_path): + base = _write(tmp_path / 'config.ini', '[Bot]\n[Wx_Command]\nenabled = true\n') + _write(tmp_path / 'local' / 'config.ini', '[Wx_Command]\nenabled = false\n') + + assert is_command_enabled(_command('wx'), read_config(str(base))) is False + + +def test_read_config_honors_configured_local_dir(tmp_path): + base = _write(tmp_path / 'config.ini', '[Bot]\nlocal_dir_path = custom\n') + _write(tmp_path / 'custom' / 'config.ini', '[Wx_Command]\nenabled = false\n') + + assert is_command_enabled(_command('wx'), read_config(str(base))) is False + + +def test_read_config_without_local_overlay(tmp_path): + base = _write(tmp_path / 'config.ini', '[Bot]\nbot_name = TestBot\n') + + config = read_config(str(base)) + + assert config.get('Bot', 'bot_name') == 'TestBot' + assert is_command_enabled(_command('wx'), config) is True + + +def test_read_config_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + read_config(str(tmp_path / 'nope.ini'))