feat: paste detailed help via FlaskPaste for !help command

!help <cmd> now pastes the command's docstring and appends the URL.
!help <plugin> pastes detail for all plugin commands.
!help (no args) pastes a full reference grouped by plugin.
Falls back gracefully when flaskpaste is not loaded or paste fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-23 22:51:55 +01:00
parent c851e82990
commit 69976196cd
2 changed files with 213 additions and 3 deletions

View File

@@ -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")

View File

@@ -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 <name>
!widget del <name>
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 <cmd> 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 <cmd> 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 <plugin> 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]