feat: add hot-reload, shorthand commands, and plugin help

- Plugin registry: add unload_plugin(), reload_plugin(), path tracking
- Bot: add load_plugin(), reload_plugin(), unload_plugin() public API
- Core plugin: add !load, !reload, !unload, !plugins commands
- Command dispatch: support unambiguous prefix matching (!h -> !help)
- Help: support !help <plugin> to show plugin description and commands
- Tests: 17 new tests covering hot-reload, prefix matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 01:15:59 +01:00
parent ad18a902dd
commit 77f9a364e6
6 changed files with 508 additions and 22 deletions
+6
View File
@@ -18,9 +18,15 @@ derp -v # Verbose/debug mode
!ping # Pong !ping # Pong
!help # List commands !help # List commands
!help <cmd> # Command help !help <cmd> # Command help
!help <plugin> # Plugin description + commands
!version # Bot version !version # Bot version
!echo <text> # Echo text back !echo <text> # Echo text back
!cert <domain> # CT log lookup (max 5 domains) !cert <domain> # CT log lookup (max 5 domains)
!load <plugin> # Hot-load a plugin
!reload <plugin> # Reload a changed plugin
!unload <plugin> # Remove a plugin
!plugins # List loaded plugins
!h # Shorthand (any unambiguous prefix works)
``` ```
## Plugin Template ## Plugin Template
+32
View File
@@ -49,9 +49,26 @@ level = "info" # Logging level: debug, info, warning, error
| `!ping` | Bot responds with "pong" | | `!ping` | Bot responds with "pong" |
| `!help` | List all available commands | | `!help` | List all available commands |
| `!help <cmd>` | Show help for a specific command | | `!help <cmd>` | Show help for a specific command |
| `!help <plugin>` | Show plugin description and its commands |
| `!version` | Show bot version | | `!version` | Show bot version |
| `!echo <text>` | Echo back text (example plugin) | | `!echo <text>` | Echo back text (example plugin) |
| `!cert <domain> [...]` | Lookup CT logs for up to 5 domains | | `!cert <domain> [...]` | Lookup CT logs for up to 5 domains |
| `!load <plugin>` | Hot-load a plugin from the plugins directory |
| `!reload <plugin>` | Reload a plugin, picking up file changes |
| `!unload <plugin>` | Unload a plugin, removing its handlers |
| `!plugins` | List loaded plugins with handler counts |
### Command Shorthand
Commands can be abbreviated to any unambiguous prefix:
```
!h -> !help (unique match)
!pi -> !ping (unique match)
!p -> error: ambiguous (ping, plugins)
```
Exact matches always take priority over prefix matches.
### `!cert` -- Certificate Transparency Lookup ### `!cert` -- Certificate Transparency Lookup
@@ -76,6 +93,21 @@ broken.test -- error: timeout
- crt.sh can be slow; the bot confirms receipt before querying - crt.sh can be slow; the bot confirms receipt before querying
- Live cert check runs only when expired CT entries exist - Live cert check runs only when expired CT entries exist
## Plugin Management
Plugins can be loaded, unloaded, and reloaded at runtime without
restarting the bot.
```
!load crtsh # Hot-load a new plugin from plugins/
!reload crtsh # Reload a changed plugin
!unload crtsh # Remove a plugin and all its handlers
!plugins # List loaded plugins with handler counts
```
The `core` plugin cannot be unloaded (prevents losing `!load`/`!reload`),
but it can be reloaded.
## Writing Plugins ## Writing Plugins
Create a `.py` file in the `plugins/` directory: Create a `.py` file in the `plugins/` directory:
+85 -10
View File
@@ -1,4 +1,6 @@
"""Core plugin: ping, help, version.""" """Core plugin: ping, help, version, plugin management."""
from collections import Counter
from derp import __version__ from derp import __version__
from derp.plugin import command from derp.plugin import command
@@ -10,22 +12,37 @@ async def cmd_ping(bot, message):
await bot.reply(message, "pong") await bot.reply(message, "pong")
@command("help", help="List commands or show command help") @command("help", help="List commands or show command/plugin help")
async def cmd_help(bot, message): async def cmd_help(bot, message):
"""Show available commands or help for a specific command. """Show available commands, or help for a specific command or plugin.
Usage: !help [command] Usage: !help [command|plugin]
""" """
parts = message.text.split(None, 2) parts = message.text.split(None, 2)
if len(parts) > 1: if len(parts) > 1:
# Help for a specific command name = parts[1].lower().lstrip(bot.prefix)
cmd_name = parts[1].lower().lstrip(bot.prefix)
handler = bot.registry.commands.get(cmd_name) # Check command first
handler = bot.registry.commands.get(name)
if handler: if handler:
help_text = handler.help or "No help available." help_text = handler.help or "No help available."
await bot.reply(message, f"{bot.prefix}{cmd_name} -- {help_text}") await bot.reply(message, f"{bot.prefix}{name} -- {help_text}")
else: return
await bot.reply(message, f"Unknown command: {cmd_name}")
# Check plugin
module = bot.registry._modules.get(name)
if module:
desc = (getattr(module, "__doc__", "") or "").split("\n")[0].strip()
cmds = sorted(
k for k, v in bot.registry.commands.items() if v.plugin == name
)
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))
return
await bot.reply(message, f"Unknown command or plugin: {name}")
return return
# List all commands # List all commands
@@ -37,3 +54,61 @@ async def cmd_help(bot, message):
async def cmd_version(bot, message): async def cmd_version(bot, message):
"""Report the running version.""" """Report the running version."""
await bot.reply(message, f"derp {__version__}") await bot.reply(message, f"derp {__version__}")
@command("load", help="Hot-load a plugin: !load <name>")
async def cmd_load(bot, message):
"""Load a new plugin from the plugins directory."""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !load <plugin>")
return
name = parts[1].lower()
ok, reason = bot.load_plugin(name)
if ok:
await bot.reply(message, f"Loaded plugin: {name} ({reason})")
else:
await bot.reply(message, f"Failed to load plugin: {reason}")
@command("reload", help="Reload a plugin: !reload <name>")
async def cmd_reload(bot, message):
"""Unload and reload a plugin, picking up file changes."""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !reload <plugin>")
return
name = parts[1].lower()
ok, reason = bot.reload_plugin(name)
if ok:
await bot.reply(message, f"Reloaded plugin: {name}")
else:
await bot.reply(message, f"Failed to reload plugin: {reason}")
@command("unload", help="Unload a plugin: !unload <name>")
async def cmd_unload(bot, message):
"""Unload a plugin, removing all its handlers."""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !unload <plugin>")
return
name = parts[1].lower()
ok, reason = bot.unload_plugin(name)
if ok:
await bot.reply(message, f"Unloaded plugin: {name}")
else:
await bot.reply(message, f"Failed to unload plugin: {reason}")
@command("plugins", help="List loaded plugins")
async def cmd_plugins(bot, message):
"""List all loaded plugins with handler counts."""
counts: Counter[str] = Counter()
for handler in bot.registry.commands.values():
counts[handler.plugin] += 1
for handlers in bot.registry.events.values():
for handler in handlers:
counts[handler.plugin] += 1
parts = [f"{name} ({counts[name]})" for name in sorted(counts)]
await bot.reply(message, f"Plugins: {', '.join(parts)}")
+56 -2
View File
@@ -7,11 +7,12 @@ import logging
from pathlib import Path from pathlib import Path
from derp.irc import IRCConnection, Message, format_msg, parse from derp.irc import IRCConnection, Message, format_msg, parse
from derp.plugin import PluginRegistry from derp.plugin import Handler, PluginRegistry
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
RECONNECT_DELAY = 30 RECONNECT_DELAY = 30
_AMBIGUOUS = object() # sentinel for ambiguous prefix matches
class Bot: class Bot:
@@ -108,15 +109,39 @@ class Bot:
parts = text[len(self.prefix):].split(None, 1) parts = text[len(self.prefix):].split(None, 1)
cmd_name = parts[0].lower() if parts else "" cmd_name = parts[0].lower() if parts else ""
handler = self.registry.commands.get(cmd_name) handler = self._resolve_command(cmd_name)
if handler is None: if handler is None:
return return
if handler is _AMBIGUOUS:
matches = [k for k in self.registry.commands if k.startswith(cmd_name)]
names = ", ".join(self.prefix + m for m in sorted(matches))
await self.reply(msg, f"Ambiguous command '{self.prefix}{cmd_name}': {names}")
return
try: try:
await handler.callback(self, msg) await handler.callback(self, msg)
except Exception: except Exception:
log.exception("error in command handler '%s'", cmd_name) log.exception("error in command handler '%s'", cmd_name)
def _resolve_command(self, name: str) -> Handler | None:
"""Resolve a command name, supporting unambiguous prefix matching.
Returns the Handler on exact or unique prefix match, the sentinel
``_AMBIGUOUS`` if multiple commands match, or None if nothing matches.
"""
# Exact match takes priority
handler = self.registry.commands.get(name)
if handler is not None:
return handler
# Prefix match
matches = [v for k, v in self.registry.commands.items() if k.startswith(name)]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
return _AMBIGUOUS # type: ignore[return-value]
return None
# -- Public API for plugins -- # -- Public API for plugins --
async def send(self, target: str, text: str) -> None: async def send(self, target: str, text: str) -> None:
@@ -158,3 +183,32 @@ class Bot:
plugins_dir = self.config["bot"].get("plugins_dir", "plugins") plugins_dir = self.config["bot"].get("plugins_dir", "plugins")
path = Path(plugins_dir) path = Path(plugins_dir)
self.registry.load_directory(path) self.registry.load_directory(path)
@property
def plugins_dir(self) -> Path:
"""Resolved path to the plugins directory."""
return Path(self.config["bot"].get("plugins_dir", "plugins"))
def load_plugin(self, name: str) -> tuple[bool, str]:
"""Hot-load a new plugin by name from the plugins directory."""
if name in self.registry._modules:
return False, f"plugin already loaded: {name}"
path = self.plugins_dir / f"{name}.py"
if not path.is_file():
return False, f"{name}.py not found"
count = self.registry.load_plugin(path)
if count < 0:
return False, f"failed to load {name}"
return True, f"{count} handlers"
def reload_plugin(self, name: str) -> tuple[bool, str]:
"""Reload a plugin, picking up any file changes."""
return self.registry.reload_plugin(name)
def unload_plugin(self, name: str) -> tuple[bool, str]:
"""Unload a plugin, removing all its handlers."""
if self.registry.unload_plugin(name):
return True, ""
if name == "core":
return False, "cannot unload core"
return False, f"plugin not loaded: {name}"
+89 -10
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
import inspect import inspect
import logging import logging
import sys
import types
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -64,6 +65,7 @@ class PluginRegistry:
self.commands: dict[str, Handler] = {} self.commands: dict[str, Handler] = {}
self.events: dict[str, list[Handler]] = {} self.events: dict[str, list[Handler]] = {}
self._modules: dict[str, Any] = {} self._modules: dict[str, Any] = {}
self._paths: dict[str, Path] = {}
def register_command(self, name: str, callback: Callable, help: str = "", def register_command(self, name: str, callback: Callable, help: str = "",
plugin: str = "") -> None: plugin: str = "") -> None:
@@ -98,23 +100,30 @@ class PluginRegistry:
count += 1 count += 1
return count return count
def load_plugin(self, path: Path) -> None: def load_plugin(self, path: Path) -> int:
"""Load a single plugin from a .py file.""" """Load a single plugin from a .py file.
Returns the number of handlers registered, or -1 on failure.
"""
plugin_name = path.stem plugin_name = path.stem
if plugin_name.startswith("_"): if plugin_name.startswith("_"):
return return -1
try: try:
spec = importlib.util.spec_from_file_location(f"derp.plugins.{plugin_name}", path) mod_name = f"derp.plugins.{plugin_name}"
if spec is None or spec.loader is None: source = path.read_text(encoding="utf-8")
log.error("failed to create spec for %s", path) code = compile(source, str(path), "exec")
return module = types.ModuleType(mod_name)
module = importlib.util.module_from_spec(spec) module.__file__ = str(path)
spec.loader.exec_module(module) sys.modules[mod_name] = module
exec(code, module.__dict__) # noqa: S102
count = self._scan_module(module, plugin_name) count = self._scan_module(module, plugin_name)
self._modules[plugin_name] = module self._modules[plugin_name] = module
self._paths[plugin_name] = path.resolve()
log.info("loaded plugin: %s (%d handlers)", plugin_name, count) log.info("loaded plugin: %s (%d handlers)", plugin_name, count)
return count
except Exception: except Exception:
log.exception("failed to load plugin: %s", path) log.exception("failed to load plugin: %s", path)
return -1
def load_directory(self, dir_path: Path) -> None: def load_directory(self, dir_path: Path) -> None:
"""Load all .py plugin files from a directory.""" """Load all .py plugin files from a directory."""
@@ -123,3 +132,73 @@ class PluginRegistry:
return return
for path in sorted(dir_path.glob("*.py")): for path in sorted(dir_path.glob("*.py")):
self.load_plugin(path) self.load_plugin(path)
def unload_plugin(self, name: str) -> bool:
"""Unload a plugin, removing all its handlers.
Returns True if the plugin was found and unloaded, False otherwise.
Refuses to unload the ``core`` plugin.
"""
if name == "core":
log.warning("refusing to unload core plugin")
return False
if name not in self._modules:
return False
# Remove commands belonging to this plugin
self.commands = {
k: v for k, v in self.commands.items() if v.plugin != name
}
# Remove event handlers belonging to this plugin
for event_type in list(self.events):
self.events[event_type] = [
h for h in self.events[event_type] if h.plugin != name
]
if not self.events[event_type]:
del self.events[event_type]
# Clean up module references
mod_name = f"derp.plugins.{name}"
sys.modules.pop(mod_name, None)
del self._modules[name]
del self._paths[name]
log.info("unloaded plugin: %s", name)
return True
def reload_plugin(self, name: str) -> tuple[bool, str]:
"""Reload a plugin from its original path.
Returns ``(True, "")`` on success, ``(False, reason)`` on failure.
"""
if name not in self._paths:
return False, f"plugin not loaded: {name}"
path = self._paths[name]
# Core can be reloaded (unload guard is bypassed)
if name == "core":
self._force_unload(name)
else:
self.unload_plugin(name)
count = self.load_plugin(path)
if count < 0:
return False, f"failed to load {name}"
return True, ""
def _force_unload(self, name: str) -> None:
"""Unload a plugin without the core guard (used by reload)."""
self.commands = {
k: v for k, v in self.commands.items() if v.plugin != name
}
for event_type in list(self.events):
self.events[event_type] = [
h for h in self.events[event_type] if h.plugin != name
]
if not self.events[event_type]:
del self.events[event_type]
mod_name = f"derp.plugins.{name}"
sys.modules.pop(mod_name, None)
self._modules.pop(name, None)
self._paths.pop(name, None)
+240
View File
@@ -3,6 +3,7 @@
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from derp.bot import _AMBIGUOUS, Bot
from derp.plugin import PluginRegistry, command, event from derp.plugin import PluginRegistry, command, event
@@ -126,3 +127,242 @@ class TestRegistry:
registry = PluginRegistry() registry = PluginRegistry()
registry.load_directory(tmp_path / "nonexistent") registry.load_directory(tmp_path / "nonexistent")
assert len(registry.commands) == 0 assert len(registry.commands) == 0
def test_load_plugin_returns_count(self, tmp_path: Path):
plugin_file = tmp_path / "counting.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command, event
@command("one", help="first")
async def cmd_one(bot, msg):
pass
@command("two", help="second")
async def cmd_two(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
"""))
registry = PluginRegistry()
count = registry.load_plugin(plugin_file)
assert count == 3
def test_load_plugin_stores_path(self, tmp_path: Path):
plugin_file = tmp_path / "pathed.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("pathed")
async def cmd(bot, msg):
pass
"""))
registry = PluginRegistry()
registry.load_plugin(plugin_file)
assert "pathed" in registry._paths
assert registry._paths["pathed"] == plugin_file.resolve()
class TestHotReload:
"""Test hot-load, unload, and reload of plugins."""
def _write_plugin(self, tmp_path: Path, name: str, code: str) -> Path:
"""Write a plugin file and return its path."""
path = tmp_path / f"{name}.py"
path.write_text(textwrap.dedent(code))
return path
def test_unload_removes_commands(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "greet", """\
from derp.plugin import command
@command("hello")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "greet.py")
assert "hello" in registry.commands
result = registry.unload_plugin("greet")
assert result is True
assert "hello" not in registry.commands
assert "greet" not in registry._modules
assert "greet" not in registry._paths
def test_unload_removes_events(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "evplug", """\
from derp.plugin import event
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "evplug.py")
assert "JOIN" in registry.events
assert len(registry.events["JOIN"]) == 1
registry.unload_plugin("evplug")
assert "JOIN" not in registry.events
def test_unload_unknown_returns_false(self):
registry = PluginRegistry()
assert registry.unload_plugin("nonexistent") is False
def test_unload_core_refused(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
assert "ping" in registry.commands
result = registry.unload_plugin("core")
assert result is False
assert "ping" in registry.commands
def test_reload_plugin(self, tmp_path: Path):
registry = PluginRegistry()
path = self._write_plugin(tmp_path, "mutable", """\
from derp.plugin import command
@command("old")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(path)
assert "old" in registry.commands
# Rewrite the file with a different command
path.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("new")
async def cmd(bot, msg):
pass
"""))
ok, reason = registry.reload_plugin("mutable")
assert ok is True
assert "old" not in registry.commands
assert "new" in registry.commands
def test_reload_unknown_returns_failure(self):
registry = PluginRegistry()
ok, reason = registry.reload_plugin("ghost")
assert ok is False
assert "not loaded" in reason
def test_reload_core_allowed(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
ok, _ = registry.reload_plugin("core")
assert ok is True
assert "ping" in registry.commands
def test_unload_preserves_other_plugins(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "keep", """\
from derp.plugin import command, event
@command("keeper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
self._write_plugin(tmp_path, "drop", """\
from derp.plugin import command, event
@command("dropper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_directory(tmp_path)
assert "keeper" in registry.commands
assert "dropper" in registry.commands
assert len(registry.events["JOIN"]) == 2
registry.unload_plugin("drop")
assert "keeper" in registry.commands
assert "dropper" not in registry.commands
assert len(registry.events["JOIN"]) == 1
class TestPrefixMatch:
"""Test shorthand / prefix matching for commands."""
@staticmethod
def _make_bot(commands: list[str]) -> Bot:
"""Create a minimal Bot with the given command names registered."""
config = {
"server": {"host": "localhost", "port": 6667, "tls": False,
"nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
}
registry = PluginRegistry()
for name in commands:
async def _noop(bot, msg):
pass
registry.register_command(name, _noop, plugin="test")
return Bot(config, registry)
def test_exact_match(self):
bot = self._make_bot(["ping", "pong", "plugins"])
handler = bot._resolve_command("ping")
assert handler is not None
assert handler.name == "ping"
def test_unique_prefix(self):
bot = self._make_bot(["ping", "help", "version"])
handler = bot._resolve_command("h")
assert handler is not None
assert handler.name == "help"
def test_unique_prefix_partial(self):
bot = self._make_bot(["ping", "plugins", "help"])
handler = bot._resolve_command("pi")
assert handler is not None
assert handler.name == "ping"
def test_ambiguous_prefix(self):
bot = self._make_bot(["ping", "plugins", "help"])
result = bot._resolve_command("p")
assert result is _AMBIGUOUS
def test_no_match(self):
bot = self._make_bot(["ping", "help"])
assert bot._resolve_command("z") is None
def test_exact_match_beats_prefix(self):
bot = self._make_bot(["help", "helper"])
handler = bot._resolve_command("help")
assert handler is not None
assert handler.name == "help"
def test_single_char_unique(self):
bot = self._make_bot(["version", "help", "ping"])
handler = bot._resolve_command("v")
assert handler is not None
assert handler.name == "version"