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'))