diff --git a/plugins/core.py b/plugins/core.py index c5b3680..337c489 100644 --- a/plugins/core.py +++ b/plugins/core.py @@ -1,11 +1,36 @@ """Core plugin: ping, help, version, plugin management.""" +import asyncio +import textwrap from collections import Counter from derp import __version__ from derp.plugin import command +def _build_cmd_detail(handler, prefix: str) -> str: + """Extract and format a command's docstring into a detail block.""" + doc = textwrap.dedent(handler.callback.__doc__ or "").strip() + if not doc: + return "" + header = f"{prefix}{handler.name}" + if handler.help: + header += f" -- {handler.help}" + return f"{header}\n{doc}" + + +async def _paste(bot, text: str) -> str | None: + """Create a paste via FlaskPaste. Returns URL or None.""" + fp = bot.registry._modules.get("flaskpaste") + if not fp: + return None + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor(None, fp.create_paste, bot, text) + except Exception: + return None + + @command("ping", help="Check if the bot is alive") async def cmd_ping(bot, message): """Respond with pong.""" @@ -27,7 +52,13 @@ async def cmd_help(bot, message): handler = bot.registry.commands.get(name) if handler and bot._plugin_allowed(handler.plugin, channel): help_text = handler.help or "No help available." - await bot.reply(message, f"{bot.prefix}{name} -- {help_text}") + reply = f"{bot.prefix}{name} -- {help_text}" + detail = _build_cmd_detail(handler, bot.prefix) + if detail: + url = await _paste(bot, detail) + if url: + reply += f" | {url}" + await bot.reply(message, reply) return # Check plugin @@ -41,7 +72,19 @@ async def cmd_help(bot, message): lines = [f"{name} -- {desc}" if desc else name] if cmds: lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}") - await bot.reply(message, " | ".join(lines)) + reply = " | ".join(lines) + # Build detail block for all plugin commands + blocks = [] + for cmd_name in cmds: + h = bot.registry.commands[cmd_name] + blk = _build_cmd_detail(h, bot.prefix) + if blk: + blocks.append(blk) + if blocks: + url = await _paste(bot, "\n\n".join(blocks)) + if url: + reply += f" | {url}" + await bot.reply(message, reply) return await bot.reply(message, f"Unknown command or plugin: {name}") @@ -52,7 +95,37 @@ async def cmd_help(bot, message): k for k, v in bot.registry.commands.items() if bot._plugin_allowed(v.plugin, channel) ) - await bot.reply(message, ", ".join(names)) + reply = ", ".join(names) + + # Build full reference grouped by plugin + plugins: dict[str, list[str]] = {} + for cmd_name in names: + h = bot.registry.commands[cmd_name] + plugins.setdefault(h.plugin, []).append(cmd_name) + blocks = [] + for plugin_name in sorted(plugins): + mod = bot.registry._modules.get(plugin_name) + desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else "" + header = f"[{plugin_name}]" + if desc: + header += f" {desc}" + cmd_lines = [] + for cmd_name in plugins[plugin_name]: + h = bot.registry.commands[cmd_name] + detail = _build_cmd_detail(h, bot.prefix) + if detail: + cmd_lines.append(detail) + else: + line = f"{bot.prefix}{cmd_name}" + if h.help: + line += f" -- {h.help}" + cmd_lines.append(line) + blocks.append(header + "\n" + "\n\n".join(cmd_lines)) + if blocks: + url = await _paste(bot, "\n\n".join(blocks)) + if url: + reply += f" | {url}" + await bot.reply(message, reply) @command("version", help="Show bot version") diff --git a/tests/test_core.py b/tests/test_core.py index 789513b..b30ffa9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,6 +3,8 @@ import asyncio import importlib.util import sys +import types +from dataclasses import dataclass from unittest.mock import MagicMock # -- Load plugin module directly --------------------------------------------- @@ -16,9 +18,22 @@ _spec.loader.exec_module(_mod) # -- Fakes ------------------------------------------------------------------- +@dataclass +class _FakeHandler: + name: str + callback: object + help: str = "" + plugin: str = "" + admin: bool = False + tier: str = "user" + + class _FakeRegistry: def __init__(self): self._bots: dict = {} + self.commands: dict = {} + self._modules: dict = {} + self.events: dict = {} class _FakeBot: @@ -26,10 +41,14 @@ class _FakeBot: self.replied: list[str] = [] self.registry = _FakeRegistry() self.nick = "derp" + self.prefix = "!" self._receive_sound = False if mumble: self._mumble = MagicMock() + def _plugin_allowed(self, plugin: str, channel) -> bool: + return True + async def reply(self, message, text: str) -> None: self.replied.append(text) @@ -90,3 +109,121 @@ class TestDeafCommand: msg = _Msg(text="!deaf") asyncio.run(_mod.cmd_deaf(bot, msg)) bot._mumble.users.myself.deafen.assert_called_once() + + +# -- Help command tests ------------------------------------------------------ + + +def _cmd_with_doc(): + """Manage widgets. + + Usage: + !widget add + !widget del + + Examples: + !widget add foo + """ + + +def _cmd_no_doc(): + pass + + +def _make_fp_module(url="https://paste.example.com/abc/raw"): + """Create a fake flaskpaste module that returns a fixed URL.""" + mod = types.ModuleType("flaskpaste") + mod.create_paste = lambda bot, text: url + return mod + + +class TestHelpCommand: + def test_help_cmd_with_paste(self): + """!help with docstring pastes detail, appends URL.""" + bot = _FakeBot() + handler = _FakeHandler( + name="widget", callback=_cmd_with_doc, + help="Manage widgets", plugin="widgets", + ) + bot.registry.commands["widget"] = handler + bot.registry._modules["flaskpaste"] = _make_fp_module() + msg = _Msg(text="!help widget") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(bot.replied) == 1 + assert "!widget -- Manage widgets" in bot.replied[0] + assert "https://paste.example.com/abc/raw" in bot.replied[0] + + def test_help_cmd_no_docstring(self): + """!help without docstring skips paste.""" + bot = _FakeBot() + handler = _FakeHandler( + name="noop", callback=_cmd_no_doc, + help="Does nothing", plugin="misc", + ) + bot.registry.commands["noop"] = handler + bot.registry._modules["flaskpaste"] = _make_fp_module() + msg = _Msg(text="!help noop") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(bot.replied) == 1 + assert "!noop -- Does nothing" in bot.replied[0] + assert "paste.example.com" not in bot.replied[0] + + def test_help_plugin_with_paste(self): + """!help pastes detail for all plugin commands.""" + bot = _FakeBot() + mod = types.ModuleType("widgets") + mod.__doc__ = "Widget management plugin." + bot.registry._modules["widgets"] = mod + bot.registry._modules["flaskpaste"] = _make_fp_module() + bot.registry.commands["widget"] = _FakeHandler( + name="widget", callback=_cmd_with_doc, + help="Manage widgets", plugin="widgets", + ) + bot.registry.commands["wstat"] = _FakeHandler( + name="wstat", callback=_cmd_no_doc, + help="Widget stats", plugin="widgets", + ) + msg = _Msg(text="!help widgets") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(bot.replied) == 1 + reply = bot.replied[0] + assert "widgets -- Widget management plugin." in reply + assert "!widget, !wstat" in reply + # Only widget has a docstring, so paste should still happen + assert "https://paste.example.com/abc/raw" in reply + + def test_help_list_with_paste(self): + """!help (no args) pastes full reference.""" + bot = _FakeBot() + bot.registry._modules["flaskpaste"] = _make_fp_module() + mod = types.ModuleType("core") + mod.__doc__ = "Core plugin." + bot.registry._modules["core"] = mod + bot.registry.commands["ping"] = _FakeHandler( + name="ping", callback=_cmd_with_doc, + help="Check alive", plugin="core", + ) + bot.registry.commands["help"] = _FakeHandler( + name="help", callback=_cmd_no_doc, + help="Show help", plugin="core", + ) + msg = _Msg(text="!help") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(bot.replied) == 1 + assert "help, ping" in bot.replied[0] + assert "https://paste.example.com/abc/raw" in bot.replied[0] + + def test_help_no_flaskpaste(self): + """Without flaskpaste loaded, help still works (no URL).""" + bot = _FakeBot() + handler = _FakeHandler( + name="widget", callback=_cmd_with_doc, + help="Manage widgets", plugin="widgets", + ) + bot.registry.commands["widget"] = handler + # No flaskpaste in _modules + msg = _Msg(text="!help widget") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(bot.replied) == 1 + assert "!widget -- Manage widgets" in bot.replied[0] + assert "https://" not in bot.replied[0]