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 <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,7 @@ async def on_join(bot, message):
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
- [Plugin API Reference](docs/API.md)
|
||||||
- [Installation](docs/INSTALL.md)
|
- [Installation](docs/INSTALL.md)
|
||||||
- [Usage Guide](docs/USAGE.md)
|
- [Usage Guide](docs/USAGE.md)
|
||||||
- [Cheatsheet](docs/CHEATSHEET.md)
|
- [Cheatsheet](docs/CHEATSHEET.md)
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
## v2.0.0 -- Multi-Server + Integrations
|
## v2.0.0 -- Multi-Server + Integrations
|
||||||
|
|
||||||
- [x] Multi-server support (per-server config, shared plugins)
|
- [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] Paste overflow (auto-paste long output to FlaskPaste, return link)
|
||||||
- [x] URL shortener integration (shorten URLs in subscription announcements)
|
- [x] URL shortener integration (shorten URLs in subscription announcements)
|
||||||
- [x] Webhook listener (HTTP endpoint for push events to channels)
|
- [x] Webhook listener (HTTP endpoint for push events to channels)
|
||||||
|
|||||||
10
TASKS.md
10
TASKS.md
@@ -1,6 +1,14 @@
|
|||||||
# derp - Tasks
|
# 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 |
|
| Pri | Status | Task |
|
||||||
|-----|--------|------|
|
|-----|--------|------|
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -3,7 +3,7 @@
|
|||||||
## Core
|
## Core
|
||||||
|
|
||||||
- [x] Multi-server support (per-server config, shared plugins)
|
- [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] Paste overflow (auto-paste long output to FlaskPaste)
|
||||||
- [x] URL shortener integration (shorten URLs in subscription announcements)
|
- [x] URL shortener integration (shorten URLs in subscription announcements)
|
||||||
- [x] Webhook listener (HTTP endpoint for push events to channels)
|
- [x] Webhook listener (HTTP endpoint for push events to channels)
|
||||||
|
|||||||
304
docs/API.md
Normal file
304
docs/API.md
Normal file
@@ -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 <key> <value>")
|
||||||
|
return
|
||||||
|
bot.state.set("note", args[1], args[2])
|
||||||
|
await bot.reply(message, f"Saved: {args[1]}")
|
||||||
|
```
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "derp"
|
name = "derp"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
description = "Asyncio IRC bot with plugin system"
|
description = "Asyncio IRC bot with plugin system"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""derp - asyncio IRC bot with plugin system."""
|
"""derp - asyncio IRC bot with plugin system."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user