Compare commits
2 Commits
c851e82990
...
ef18915807
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef18915807 | ||
|
|
69976196cd |
15
TASKS.md
15
TASKS.md
@@ -1,6 +1,18 @@
|
|||||||
# derp - Tasks
|
# derp - Tasks
|
||||||
|
|
||||||
## Current Sprint -- MusicBrainz Fallback (2026-02-23)
|
## Current Sprint -- Enhanced Help with FlaskPaste (2026-02-23)
|
||||||
|
|
||||||
|
| Pri | Status | Task |
|
||||||
|
|-----|--------|------|
|
||||||
|
| P0 | [x] | `!help <cmd>` pastes docstring detail via FlaskPaste, appends URL |
|
||||||
|
| P0 | [x] | `!help <plugin>` pastes all plugin command details |
|
||||||
|
| P0 | [x] | `!help` (no args) pastes full reference grouped by plugin |
|
||||||
|
| P1 | [x] | Graceful fallback when FlaskPaste not loaded or paste fails |
|
||||||
|
| P1 | [x] | Helper functions: `_build_cmd_detail`, `_paste` |
|
||||||
|
| P1 | [x] | Tests: 5 new cases in test_core.py (9 total) |
|
||||||
|
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
|
||||||
|
|
||||||
|
## Previous Sprint -- MusicBrainz Fallback (2026-02-23)
|
||||||
|
|
||||||
| Pri | Status | Task |
|
| Pri | Status | Task |
|
||||||
|-----|--------|------|
|
|-----|--------|------|
|
||||||
@@ -306,6 +318,7 @@
|
|||||||
|
|
||||||
| Date | Task |
|
| Date | Task |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-02-23 | Enhanced `!help` with FlaskPaste detail pages (docstrings, grouped reference) |
|
||||||
| 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) |
|
| 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) |
|
||||||
| 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) |
|
| 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) |
|
||||||
| 2026-02-21 | v2.3.0 (pymumble rewrite, music playback, fades, seek, kept library) |
|
| 2026-02-21 | v2.3.0 (pymumble rewrite, music playback, fades, seek, kept library) |
|
||||||
|
|||||||
@@ -86,15 +86,19 @@ Profile data written on graceful shutdown when bot runs with `--cprofile`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
!ping # Pong
|
!ping # Pong
|
||||||
!help # List commands
|
!help # List commands + paste full reference
|
||||||
!help <cmd> # Command help
|
!help <cmd> # Command help + paste docstring detail
|
||||||
!help <plugin> # Plugin description + commands
|
!help <plugin> # Plugin info + paste command details
|
||||||
!version # Bot version
|
!version # Bot version
|
||||||
!uptime # Bot uptime
|
!uptime # Bot uptime
|
||||||
!echo <text> # Echo text back
|
!echo <text> # Echo text back
|
||||||
!h # Shorthand (any unambiguous prefix works)
|
!h # Shorthand (any unambiguous prefix works)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Detailed help output (docstrings, subcommands, examples) is pasted to
|
||||||
|
FlaskPaste and appended as a URL. Falls back gracefully if FlaskPaste
|
||||||
|
is not loaded.
|
||||||
|
|
||||||
## Permission Tiers
|
## Permission Tiers
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ unchanged. The server name is derived from the hostname automatically.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!ping` | Bot responds with "pong" |
|
| `!ping` | Bot responds with "pong" |
|
||||||
| `!help` | List all available commands |
|
| `!help` | List all commands + paste full reference |
|
||||||
| `!help <cmd>` | Show help for a specific command |
|
| `!help <cmd>` | Show help + paste detailed docstring |
|
||||||
| `!help <plugin>` | Show plugin description and its commands |
|
| `!help <plugin>` | Show plugin description + paste command details |
|
||||||
| `!version` | Show bot version |
|
| `!version` | Show bot version |
|
||||||
| `!uptime` | Show how long the bot has been running |
|
| `!uptime` | Show how long the bot has been running |
|
||||||
| `!echo <text>` | Echo back text (example plugin) |
|
| `!echo <text>` | Echo back text (example plugin) |
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
"""Core plugin: ping, help, version, plugin management."""
|
"""Core plugin: ping, help, version, plugin management."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import textwrap
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from derp import __version__
|
from derp import __version__
|
||||||
from derp.plugin import command
|
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")
|
@command("ping", help="Check if the bot is alive")
|
||||||
async def cmd_ping(bot, message):
|
async def cmd_ping(bot, message):
|
||||||
"""Respond with pong."""
|
"""Respond with pong."""
|
||||||
@@ -27,7 +52,13 @@ async def cmd_help(bot, message):
|
|||||||
handler = bot.registry.commands.get(name)
|
handler = bot.registry.commands.get(name)
|
||||||
if handler and bot._plugin_allowed(handler.plugin, channel):
|
if handler and bot._plugin_allowed(handler.plugin, channel):
|
||||||
help_text = handler.help or "No help available."
|
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
|
return
|
||||||
|
|
||||||
# Check plugin
|
# Check plugin
|
||||||
@@ -41,7 +72,19 @@ async def cmd_help(bot, message):
|
|||||||
lines = [f"{name} -- {desc}" if desc else name]
|
lines = [f"{name} -- {desc}" if desc else name]
|
||||||
if cmds:
|
if cmds:
|
||||||
lines.append(f"Commands: {', '.join(bot.prefix + c for c in 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
|
return
|
||||||
|
|
||||||
await bot.reply(message, f"Unknown command or plugin: {name}")
|
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()
|
k for k, v in bot.registry.commands.items()
|
||||||
if bot._plugin_allowed(v.plugin, channel)
|
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")
|
@command("version", help="Show bot version")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
|
import types
|
||||||
|
from dataclasses import dataclass
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
# -- Load plugin module directly ---------------------------------------------
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
@@ -16,9 +18,22 @@ _spec.loader.exec_module(_mod)
|
|||||||
# -- Fakes -------------------------------------------------------------------
|
# -- Fakes -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeHandler:
|
||||||
|
name: str
|
||||||
|
callback: object
|
||||||
|
help: str = ""
|
||||||
|
plugin: str = ""
|
||||||
|
admin: bool = False
|
||||||
|
tier: str = "user"
|
||||||
|
|
||||||
|
|
||||||
class _FakeRegistry:
|
class _FakeRegistry:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._bots: dict = {}
|
self._bots: dict = {}
|
||||||
|
self.commands: dict = {}
|
||||||
|
self._modules: dict = {}
|
||||||
|
self.events: dict = {}
|
||||||
|
|
||||||
|
|
||||||
class _FakeBot:
|
class _FakeBot:
|
||||||
@@ -26,10 +41,14 @@ class _FakeBot:
|
|||||||
self.replied: list[str] = []
|
self.replied: list[str] = []
|
||||||
self.registry = _FakeRegistry()
|
self.registry = _FakeRegistry()
|
||||||
self.nick = "derp"
|
self.nick = "derp"
|
||||||
|
self.prefix = "!"
|
||||||
self._receive_sound = False
|
self._receive_sound = False
|
||||||
if mumble:
|
if mumble:
|
||||||
self._mumble = MagicMock()
|
self._mumble = MagicMock()
|
||||||
|
|
||||||
|
def _plugin_allowed(self, plugin: str, channel) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
async def reply(self, message, text: str) -> None:
|
async def reply(self, message, text: str) -> None:
|
||||||
self.replied.append(text)
|
self.replied.append(text)
|
||||||
|
|
||||||
@@ -90,3 +109,121 @@ class TestDeafCommand:
|
|||||||
msg = _Msg(text="!deaf")
|
msg = _Msg(text="!deaf")
|
||||||
asyncio.run(_mod.cmd_deaf(bot, msg))
|
asyncio.run(_mod.cmd_deaf(bot, msg))
|
||||||
bot._mumble.users.myself.deafen.assert_called_once()
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user