# 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 HTTP/TCP helpers with optional SOCKS5 proxy routing. All functions accept a `proxy` parameter (default `True`) to toggle SOCKS5. | Function | Signature | Description | |----------|-----------|-------------| | `urlopen` | `(req, *, timeout=None, context=None, retries=None, proxy=True)` | HTTP request with optional SOCKS5, connection pooling, retries | | `build_opener` | `(*handlers, context=None, proxy=True)` | Build URL opener, optionally with SOCKS5 handler | | `create_connection` | `(address, *, timeout=None, proxy=True)` | TCP `socket.create_connection` with optional SOCKS5, retries | | `open_connection` | `(host, port, *, timeout=None, proxy=True)` | Async `asyncio.open_connection` with optional SOCKS5, 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 | --- ## `derp.teams` -- Teams Adapter Alternative bot adapter for Microsoft Teams via outgoing/incoming webhooks. Exposes the same plugin API as `derp.bot.Bot` so protocol-agnostic plugins work without modification. ### `TeamsMessage` dataclass Duck-typed compatible with IRC `Message`: | Field | Type | Description | |-------|------|-------------| | `raw` | `dict` | Original Activity JSON | | `nick` | `str \| None` | Sender display name | | `prefix` | `str \| None` | Sender AAD object ID (for ACL) | | `text` | `str \| None` | Message body (stripped of @mention) | | `target` | `str \| None` | Conversation/channel ID | | `is_channel` | `bool` | Always `True` (outgoing webhooks) | | `command` | `str` | Always `"PRIVMSG"` (compat shim) | | `params` | `list[str]` | `[target, text]` | | `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) | | `_replies` | `list[str]` | Reply buffer (unstable) | ### `TeamsBot` Same stable attributes and methods as `Bot`: | Attribute | Type | Description | |-----------|------|-------------| | `name` | `str` | Always `"teams"` | | `config` | `dict` | Merged TOML configuration | | `nick` | `str` | Bot display name (`teams.bot_name`) | | `prefix` | `str` | Command prefix (from `[bot]`) | | `state` | `StateStore` | Persistent key-value storage | | `registry` | `PluginRegistry` | Shared command and event registry | **Sending messages** -- same signatures, different transport: | Method | Behaviour | |--------|-----------| | `send(target, text)` | POST to incoming webhook URL | | `reply(msg, text)` | Append to `msg._replies` (HTTP response) | | `long_reply(msg, lines, *, label="")` | Paste overflow, appends to replies | | `action(target, text)` | Italic text via incoming webhook | | `shorten_url(url)` | Same FlaskPaste integration | **IRC no-ops** (debug log, no error): `join`, `part`, `kick`, `mode`, `set_topic` **Plugin management** -- delegates to shared registry: `load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin` **Permission tiers** -- same model, exact AAD object ID matching: `_get_tier(msg)`, `_is_admin(msg)` --- ## `derp.telegram` -- Telegram Adapter Alternative bot adapter for Telegram via long-polling (`getUpdates`). All HTTP routed through SOCKS5 proxy. Exposes the same plugin API as `derp.bot.Bot` so protocol-agnostic plugins work without modification. ### `TelegramMessage` dataclass Duck-typed compatible with IRC `Message`: | Field | Type | Description | |-------|------|-------------| | `raw` | `dict` | Original Telegram Update | | `nick` | `str \| None` | Sender first_name (or username fallback) | | `prefix` | `str \| None` | Sender user_id as string (for ACL) | | `text` | `str \| None` | Message body (stripped of @bot suffix) | | `target` | `str \| None` | chat_id as string | | `is_channel` | `bool` | `True` for groups, `False` for DMs | | `command` | `str` | Always `"PRIVMSG"` (compat shim) | | `params` | `list[str]` | `[target, text]` | | `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) | ### `TelegramBot` Same stable attributes and methods as `Bot`: | Attribute | Type | Description | |-----------|------|-------------| | `name` | `str` | Always `"telegram"` | | `config` | `dict` | Merged TOML configuration | | `nick` | `str` | Bot display name (from `getMe`) | | `prefix` | `str` | Command prefix (from `[telegram]` or `[bot]`) | | `state` | `StateStore` | Persistent key-value storage | | `registry` | `PluginRegistry` | Shared command and event registry | **Sending messages** -- same signatures, Telegram API transport: | Method | Behaviour | |--------|-----------| | `send(target, text)` | `sendMessage` API call (proxied, rate-limited) | | `reply(msg, text)` | `send(msg.target, text)` | | `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC | | `action(target, text)` | Italic Markdown text via `sendMessage` | | `shorten_url(url)` | Same FlaskPaste integration | **Message splitting**: messages > 4096 chars split at line boundaries. **IRC no-ops** (debug log, no error): `join`, `part`, `kick`, `mode`, `set_topic` **Plugin management** -- delegates to shared registry: `load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin` **Permission tiers** -- same model, exact user_id string matching: `_get_tier(msg)`, `_is_admin(msg)` ### Helper Functions | Function | Signature | Description | |----------|-----------|-------------| | `_strip_bot_suffix` | `(text: str, bot_username: str) -> str` | Strip `@username` from command text | | `_build_telegram_message` | `(update: dict, bot_username: str) -> TelegramMessage \| None` | Parse Telegram Update into message | | `_split_message` | `(text: str, max_len: int = 4096) -> list[str]` | Split long text at line boundaries | --- ## `derp.mumble` -- Mumble Adapter Alternative bot adapter for Mumble via TCP/TLS protobuf control channel (text chat only). All TCP routed through SOCKS5 proxy. Uses a minimal built-in protobuf encoder/decoder (no external dependency). Exposes the same plugin API as `derp.bot.Bot`. ### `MumbleMessage` dataclass Duck-typed compatible with IRC `Message`: | Field | Type | Description | |-------|------|-------------| | `raw` | `dict` | Decoded protobuf fields | | `nick` | `str \| None` | Sender username (from session lookup) | | `prefix` | `str \| None` | Sender username (for ACL) | | `text` | `str \| None` | Message body (HTML stripped) | | `target` | `str \| None` | channel_id as string (or `"dm"`) | | `is_channel` | `bool` | `True` for channel msgs, `False` for DMs | | `command` | `str` | Always `"PRIVMSG"` (compat shim) | | `params` | `list[str]` | `[target, text]` | | `tags` | `dict[str, str]` | Empty dict (no IRCv3 tags) | ### `MumbleBot` Same stable attributes and methods as `Bot`: | Attribute | Type | Description | |-----------|------|-------------| | `name` | `str` | Always `"mumble"` | | `config` | `dict` | Merged TOML configuration | | `nick` | `str` | Bot username (from config) | | `prefix` | `str` | Command prefix (from `[mumble]` or `[bot]`) | | `state` | `StateStore` | Persistent key-value storage | | `registry` | `PluginRegistry` | Shared command and event registry | **Sending messages** -- same signatures, Mumble protobuf transport: | Method | Behaviour | |--------|-----------| | `send(target, text)` | TextMessage to channel (HTML-escaped) | | `reply(msg, text)` | `send(msg.target, text)` | | `long_reply(msg, lines, *, label="")` | Paste overflow, same logic as IRC | | `action(target, text)` | Italic HTML text (`...`) | | `shorten_url(url)` | Same FlaskPaste integration | **IRC no-ops** (debug log, no error): `join`, `part`, `kick`, `mode`, `set_topic` **Plugin management** -- delegates to shared registry: `load_plugins`, `load_plugin`, `reload_plugin`, `unload_plugin` **Permission tiers** -- same model, exact username string matching: `_get_tier(msg)`, `_is_admin(msg)` ### Protobuf Codec (internal) Minimal protobuf wire format helpers -- not part of the stable API: | Function | Description | |----------|-------------| | `_encode_varint(value)` | Encode unsigned int as protobuf varint | | `_decode_varint(data, offset)` | Decode varint, returns `(value, offset)` | | `_encode_field(num, wire_type, value)` | Encode a single protobuf field | | `_decode_fields(data)` | Decode payload into `{field_num: [values]}` | | `_pack_msg(msg_type, payload)` | Wrap payload in 6-byte Mumble header | | `_unpack_header(data)` | Unpack header into `(msg_type, length)` | ### Helper Functions | Function | Signature | Description | |----------|-----------|-------------| | `_strip_html` | `(text: str) -> str` | Strip HTML tags and unescape entities | | `_escape_html` | `(text: str) -> str` | Escape text for Mumble HTML messages | | `_build_mumble_message` | `(fields, users, our_session) -> MumbleMessage \| None` | Build message from decoded TextMessage fields | --- ## 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]}") ```