Add `proxy` config option to server (IRC), teams, telegram, and mumble sections. IRC defaults to false (preserving current direct-connect behavior); all others default to true. The `derp.http` module now accepts `proxy=True/False` on urlopen, create_connection, open_connection, and build_opener -- when false, uses stdlib directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
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:
| 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
@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".
@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 (<i>...</i>) |
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:
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:
args = message.text.split(None, 1)[1] if " " in message.text else ""
Plugin Boilerplate
Minimal command plugin
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:
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:
@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]}")