From c8879f6089f616c111c9c3794ac98bb0a3880b14 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 19:22:47 +0100 Subject: [PATCH] feat: add stable plugin API reference and bump to v2.0.0 Document the full public plugin surface (decorators, bot methods, IRC primitives, state store, HTTP/DNS helpers) with semver stability guarantees and breaking-change policy. Bump version from 0.1.0 to 2.0.0. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + ROADMAP.md | 2 +- TASKS.md | 10 +- TODO.md | 2 +- docs/API.md | 304 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/derp/__init__.py | 2 +- 7 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 docs/API.md diff --git a/README.md b/README.md index f00c2f0..a0f3ba6 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ async def on_join(bot, message): ## Documentation +- [Plugin API Reference](docs/API.md) - [Installation](docs/INSTALL.md) - [Usage Guide](docs/USAGE.md) - [Cheatsheet](docs/CHEATSHEET.md) diff --git a/ROADMAP.md b/ROADMAP.md index ec6a11e..4a48ae4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ ## v2.0.0 -- Multi-Server + Integrations - [x] Multi-server support (per-server config, shared plugins) -- [ ] Stable plugin API (versioned, breaking change policy) +- [x] Stable plugin API (versioned, breaking change policy) - [x] Paste overflow (auto-paste long output to FlaskPaste, return link) - [x] URL shortener integration (shorten URLs in subscription announcements) - [x] Webhook listener (HTTP endpoint for push events to channels) diff --git a/TASKS.md b/TASKS.md index 259dd7c..0927979 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,14 @@ # derp - Tasks -## Current Sprint -- v2.0.0 Multi-Server (2026-02-21) +## Current Sprint -- v2.0.0 Stable API (2026-02-21) + +| Pri | Status | Task | +|-----|--------|------| +| P0 | [x] | Version bump to 2.0.0 (`__init__.py`, `pyproject.toml`) | +| P0 | [x] | `docs/API.md` -- plugin API reference with semver policy | +| P2 | [x] | Documentation update (README.md, ROADMAP.md, TODO.md, TASKS.md) | + +## Previous Sprint -- v2.0.0 Multi-Server (2026-02-21) | Pri | Status | Task | |-----|--------|------| diff --git a/TODO.md b/TODO.md index 377a15c..e416509 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ ## Core - [x] Multi-server support (per-server config, shared plugins) -- [ ] Stable plugin API (versioned, breaking change policy) +- [x] Stable plugin API (versioned, breaking change policy) - [x] Paste overflow (auto-paste long output to FlaskPaste) - [x] URL shortener integration (shorten URLs in subscription announcements) - [x] Webhook listener (HTTP endpoint for push events to channels) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..cc2b802 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,304 @@ +# derp - Plugin API Reference + +Stable public surface for plugin authors. Covers decorators, bot +methods, IRC primitives, state persistence, and network helpers. + +## Stability Guarantee + +Symbols documented below follow [semver](https://semver.org/): + +| Change type | Allowed in | +|-------------|------------| +| Breaking (remove, rename, change signature) | Major only (3.0, 4.0, ...) | +| Additions (new functions, parameters, fields) | Minor (2.1, 2.2, ...) | +| Bug fixes (behavior corrections) | Patch (2.0.1, 2.0.2, ...) | + +**Deprecation policy:** deprecated in a minor release, removed in the +next major. Deprecated symbols emit a log warning. + +**Extension points:** attributes prefixed with `_` are documented for +reference but considered unstable -- they may change in minor releases. + +--- + +## `derp.plugin` -- Decorators & Registry + +### Decorators + +```python +@command(name: str, help: str = "", admin: bool = False, tier: str = "") +``` + +Register an async function as a bot command. If `tier` is empty, it +defaults to `"admin"` when `admin=True`, otherwise `"user"`. + +```python +@event(event_type: str) +``` + +Register an async function as an IRC event handler. The `event_type` +is uppercased automatically (e.g. `"join"` becomes `"JOIN"`). + +### Constants + +| Name | Type | Value | +|------|------|-------| +| `TIERS` | `tuple[str, ...]` | `("user", "trusted", "oper", "admin")` | + +### `Handler` dataclass + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | `str` | -- | Command or event name | +| `callback` | `Callable` | -- | Async handler function | +| `help` | `str` | `""` | Help text | +| `plugin` | `str` | `""` | Source plugin name | +| `admin` | `bool` | `False` | Legacy admin flag | +| `tier` | `str` | `"user"` | Required permission tier | + +### `PluginRegistry` + +| Method | Signature | Description | +|--------|-----------|-------------| +| `register_command` | `(name, callback, help="", plugin="", admin=False, tier="user")` | Register a command handler | +| `register_event` | `(event_type, callback, plugin="")` | Register an event handler | +| `load_plugin` | `(path: Path) -> int` | Load plugin from `.py` file; returns handler count or -1 | +| `load_directory` | `(dir_path: Path) -> None` | Load all `.py` plugins from a directory | +| `unload_plugin` | `(name: str) -> bool` | Unload plugin (refuses `core`); returns success | +| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload from original path; returns `(ok, reason)` | + +**Extension points (unstable):** + +| Attribute | Type | Description | +|-----------|------|-------------| +| `_modules` | `dict[str, Any]` | Loaded plugin modules by name | +| `_paths` | `dict[str, Path]` | File paths of loaded plugins | + +--- + +## `derp.bot` -- Bot Instance + +### Stable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `str` | Server/bot instance name | +| `config` | `dict` | Merged TOML configuration | +| `nick` | `str` | Current IRC nick | +| `prefix` | `str` | Command prefix (e.g. `"!"`) | +| `state` | `StateStore` | Persistent key-value storage | +| `registry` | `PluginRegistry` | Command and event registry | + +### Sending Messages + +| Method | Signature | Description | +|--------|-----------|-------------| +| `send` | `(target: str, text: str) -> None` | Send PRIVMSG (rate-limited, auto-split) | +| `reply` | `(msg: Message, text: str) -> None` | Reply to channel or PM source | +| `long_reply` | `(msg: Message, lines: list[str], *, label: str = "") -> None` | Reply with paste overflow for long output | +| `action` | `(target: str, text: str) -> None` | Send CTCP ACTION (/me) | + +### IRC Control + +| Method | Signature | Description | +|--------|-----------|-------------| +| `join` | `(channel: str) -> None` | Join a channel | +| `part` | `(channel: str, reason: str = "") -> None` | Part a channel | +| `quit` | `(reason: str = "bye") -> None` | Quit server and stop bot | +| `kick` | `(channel: str, nick: str, reason: str = "") -> None` | Kick user from channel | +| `mode` | `(target: str, mode_str: str, *args: str) -> None` | Set channel/user mode | +| `set_topic` | `(channel: str, topic: str) -> None` | Set channel topic | + +### Utility + +| Method | Signature | Description | +|--------|-----------|-------------| +| `shorten_url` | `(url: str) -> str` | Shorten URL via FlaskPaste; returns original on failure | +| `load_plugins` | `(plugins_dir: str \| Path \| None = None) -> None` | Load all plugins from directory | +| `load_plugin` | `(name: str) -> tuple[bool, str]` | Hot-load a plugin by name | +| `reload_plugin` | `(name: str) -> tuple[bool, str]` | Reload a plugin | +| `unload_plugin` | `(name: str) -> tuple[bool, str]` | Unload a plugin | + +### Extension Points (unstable) + +| Attribute / Method | Description | +|--------------------|-------------| +| `_pstate` | Per-bot plugin runtime state dict | +| `_get_tier(msg)` | Determine sender's permission tier | +| `_is_admin(msg)` | Check if sender is admin | +| `_dispatch_command(msg)` | Parse and dispatch a command from PRIVMSG | +| `_spawn(coro, *, name=)` | Spawn a tracked background task | +| `registry._modules` | Direct access to loaded plugin modules | + +--- + +## `derp.irc` -- IRC Protocol + +### `Message` dataclass + +| Field | Type | Description | +|-------|------|-------------| +| `raw` | `str` | Original IRC line | +| `prefix` | `str \| None` | Sender prefix (`nick!user@host`) | +| `nick` | `str \| None` | Sender nick (extracted from prefix) | +| `command` | `str` | IRC command (uppercased) | +| `params` | `list[str]` | Command parameters | +| `tags` | `dict[str, str]` | IRCv3 message tags | + +| Property | Type | Description | +|----------|------|-------------| +| `target` | `str \| None` | First param (channel or nick) | +| `text` | `str \| None` | Trailing text (last param) | +| `is_channel` | `bool` | Whether target starts with `#` or `&` | + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `parse` | `(line: str) -> Message` | Parse a raw IRC line | +| `format_msg` | `(command: str, *params: str) -> str` | Format an IRC command | + +### `IRCConnection` + +| Method | Signature | Description | +|--------|-----------|-------------| +| `connect` | `() -> None` | Open TCP/TLS connection | +| `send` | `(line: str) -> None` | Send raw IRC line (appends CRLF) | +| `readline` | `() -> str \| None` | Read one line; `None` on EOF | +| `close` | `() -> None` | Close connection | +| `connected` | *(property)* `-> bool` | Whether connection is open | + +--- + +## `derp.state` -- Persistent Storage + +### `StateStore` + +SQLite-backed key-value store. Each plugin gets its own namespace. + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` | `(plugin: str, key: str, default: str \| None = None) -> str \| None` | Get a value | +| `set` | `(plugin: str, key: str, value: str) -> None` | Set a value (upsert) | +| `delete` | `(plugin: str, key: str) -> bool` | Delete a key; returns `True` if removed | +| `keys` | `(plugin: str) -> list[str]` | List all keys for a plugin | +| `clear` | `(plugin: str) -> int` | Delete all state for a plugin; returns count | +| `close` | `() -> None` | Close the database connection | + +--- + +## `derp.http` -- HTTP & Network + +All outbound traffic routes through the configured SOCKS5 proxy. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `urlopen` | `(req, *, timeout=None, context=None, retries=None)` | Proxy-aware HTTP request with connection pooling and retries | +| `build_opener` | `(*handlers, context=None)` | Proxy-aware `urllib.request.build_opener` replacement | +| `create_connection` | `(address, *, timeout=None)` | SOCKS5-proxied `socket.create_connection` with retries | +| `open_connection` | `(host, port, *, timeout=None)` | SOCKS5-proxied `asyncio.open_connection` with retries | + +--- + +## `derp.dns` -- DNS Helpers + +Wire-format encode/decode for raw DNS queries and responses. + +### Constants + +| Name | Type | Description | +|------|------|-------------| +| `QTYPES` | `dict[str, int]` | Query type name to number (`A`, `NS`, `CNAME`, `SOA`, `PTR`, `MX`, `TXT`, `AAAA`) | +| `QTYPE_NAMES` | `dict[int, str]` | Reverse mapping (number to name) | +| `RCODES` | `dict[int, str]` | Response code to name | +| `TOR_DNS_ADDR` | `str` | Tor DNS resolver address | +| `TOR_DNS_PORT` | `int` | Tor DNS resolver port | + +### Functions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_resolver` | `() -> str` | First IPv4 nameserver from `/etc/resolv.conf` | +| `encode_name` | `(name: str) -> bytes` | Encode domain to DNS wire format | +| `decode_name` | `(data: bytes, offset: int) -> tuple[str, int]` | Decode DNS name with pointer compression | +| `build_query` | `(name: str, qtype: int) -> bytes` | Build a DNS query packet | +| `parse_rdata` | `(rtype: int, data: bytes, offset: int, rdlength: int) -> str` | Parse an RR's rdata to string | +| `parse_response` | `(data: bytes) -> tuple[int, list[str]]` | Parse DNS response; returns `(rcode, values)` | +| `reverse_name` | `(addr: str) -> str` | Convert IP to reverse DNS name | + +--- + +## Handler Signatures + +All command and event handlers are async functions receiving `bot` and +`message`: + +```python +async def cmd_name(bot: Bot, message: Message) -> None: ... +async def on_event(bot: Bot, message: Message) -> None: ... +``` + +The `message.text` contains the full message text including the command +prefix and name. To extract arguments: + +```python +args = message.text.split(None, 1)[1] if " " in message.text else "" +``` + +--- + +## Plugin Boilerplate + +### Minimal command plugin + +```python +from derp.plugin import command + +@command("greet", help="Say hello") +async def cmd_greet(bot, message): + await bot.reply(message, f"Hello, {message.nick}!") +``` + +### Stateful plugin with `_ps(bot)` pattern + +Plugins that need per-bot runtime state use a `_ps(bot)` helper to +namespace state in `bot._pstate`: + +```python +from derp.plugin import command, event + +_NS = "myplugin" + +def _ps(bot): + """Return per-bot plugin state, initialising on first call.""" + if _NS not in bot._pstate: + bot._pstate[_NS] = {"counter": 0} + return bot._pstate[_NS] + +@command("count", help="Increment counter") +async def cmd_count(bot, message): + ps = _ps(bot) + ps["counter"] += 1 + await bot.reply(message, f"Count: {ps['counter']}") + +@event("JOIN") +async def on_join(bot, message): + if message.nick != bot.nick: + ps = _ps(bot) + ps["counter"] += 1 +``` + +### Persistent state + +Use `bot.state` for data that survives restarts: + +```python +@command("note", help="Save a note") +async def cmd_note(bot, message): + args = message.text.split(None, 2) + if len(args) < 3: + await bot.reply(message, "Usage: !note ") + return + bot.state.set("note", args[1], args[2]) + await bot.reply(message, f"Saved: {args[1]}") +``` diff --git a/pyproject.toml b/pyproject.toml index 665aac2..974ecf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "derp" -version = "0.1.0" +version = "2.0.0" description = "Asyncio IRC bot with plugin system" requires-python = ">=3.11" license = "MIT" diff --git a/src/derp/__init__.py b/src/derp/__init__.py index 15a3db9..98f51bd 100644 --- a/src/derp/__init__.py +++ b/src/derp/__init__.py @@ -1,3 +1,3 @@ """derp - asyncio IRC bot with plugin system.""" -__version__ = "0.1.0" +__version__ = "2.0.0"