feat: add IRCv3 cap negotiation, channel management, state persistence

Implement CAP LS 302 flow with configurable ircv3_caps list, replacing
the minimal SASL-only registration. Parse IRCv3 message tags (@key=value)
with proper value unescaping. Add channel management plugin (kick, ban,
unban, topic, mode) and bot API methods. Add SQLite key-value StateStore
for plugin state persistence with !state inspection command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 03:07:06 +01:00
parent 4a2960b288
commit f86cd1ad49
14 changed files with 614 additions and 49 deletions

View File

@@ -22,7 +22,7 @@ make down # Stop
## Features
- Async IRC over plain TCP or TLS (SASL PLAIN auth)
- Async IRC over plain TCP or TLS (SASL PLAIN auth, IRCv3 CAP negotiation)
- Plugin system with `@command` and `@event` decorators
- Hot-reload: load, unload, reload plugins at runtime
- Admin permission system (hostmask patterns + IRCOP detection)
@@ -35,7 +35,7 @@ make down # Stop
| Plugin | Commands | Description |
|--------|----------|-------------|
| core | ping, help, version, uptime, whoami, admins, load, reload, unload, plugins | Bot management |
| core | ping, help, version, uptime, whoami, admins, load, reload, unload, plugins, state | Bot management |
| dns | dns | Raw UDP DNS resolver (A/AAAA/MX/NS/TXT/CNAME/PTR/SOA) |
| encode | encode, decode | Base64, hex, URL, ROT13 |
| hash | hash, hashid | Hash generation + type identification |
@@ -61,6 +61,7 @@ make down # Stop
| headers | headers | HTTP header fingerprinting |
| exploitdb | exploitdb | Exploit-DB search (local CSV) |
| payload | payload | SQLi/XSS/SSTI/LFI/CMDi/XXE templates |
| chanmgmt | kick, ban, unban, topic, mode | Channel management (admin) |
| example | echo | Demo plugin |
## Writing Plugins

View File

@@ -41,12 +41,12 @@
## v0.4.0 -- Wave 3 Plugins (Local Databases) (done)
- [ ] GeoIP plugin (MaxMind GeoLite2-City mmdb)
- [ ] ASN plugin (GeoLite2-ASN mmdb)
- [ ] Tor exit node check (local list, daily refresh)
- [ ] IP reputation plugin (Firehol blocklist feeds)
- [ ] CVE lookup plugin (local NVD JSON feed)
- [ ] Data update script (cron-friendly, all local DBs)
- [x] GeoIP plugin (MaxMind GeoLite2-City mmdb)
- [x] ASN plugin (GeoLite2-ASN mmdb)
- [x] Tor exit node check (local list, daily refresh)
- [x] IP reputation plugin (Firehol blocklist feeds)
- [x] CVE lookup plugin (local NVD JSON feed)
- [x] Data update script (cron-friendly, all local DBs)
## v0.5.0 -- Wave 4 Plugins (Advanced) (done)
@@ -57,11 +57,11 @@
- [x] ExploitDB search (local CSV clone)
- [x] Payload template library (SQLi, XSS, SSTI, LFI, CMDi, XXE)
## v1.0.0 -- Stable
## v1.0.0 -- Stable (done)
- [ ] Multi-server support
- [ ] IRCv3 capability negotiation
- [ ] Message tags support
- [x] IRCv3 capability negotiation (CAP LS 302)
- [x] Message tags support (IRCv3 @tags parsing)
- [ ] Stable plugin API (versioned)
- [ ] Channel management commands (kick, ban, topic)
- [ ] Plugin state persistence (SQLite)
- [x] Channel management commands (kick, ban, unban, topic, mode)
- [x] Plugin state persistence (SQLite key-value store)

View File

@@ -1,21 +1,23 @@
# derp - Tasks
## Current Sprint -- v0.5.0 Wave 4 (2026-02-15)
## Current Sprint -- v1.0.0 Stable (2026-02-15)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | Opslog plugin (SQLite per-channel notes) |
| P0 | [x] | Note plugin (per-channel key-value store) |
| P0 | [x] | Subdomain plugin (crt.sh + DNS brute force) |
| P0 | [x] | Headers plugin (HTTP header fingerprinting) |
| P0 | [x] | ExploitDB search plugin (local CSV clone) |
| P0 | [x] | Payload template plugin (SQLi, XSS, SSTI, LFI, CMDi, XXE) |
| P0 | [x] | IRCv3 CAP LS 302 negotiation |
| P0 | [x] | IRCv3 message tag parsing |
| P0 | [x] | Channel management plugin (kick, ban, unban, topic, mode) |
| P0 | [x] | Plugin state persistence (SQLite key-value store) |
| P0 | [x] | Bot API: kick, mode, set_topic methods |
| P0 | [x] | Core !state command for inspection |
| P1 | [x] | Tests: tag parsing, state store CRUD |
| P1 | [x] | Documentation update |
## Completed
| Date | Task |
|------|------|
| 2026-02-15 | v1.0.0 (IRCv3, chanmgmt, state persistence) |
| 2026-02-15 | Wave 4 (opslog, note, subdomain, headers, exploitdb, payload) |
| 2026-02-15 | Wave 3 plugins (geoip, asn, torcheck, iprep, cve) + update script |
| 2026-02-15 | Admin/owner permission system (hostmask + IRCOP) |

View File

@@ -8,6 +8,7 @@ realname = "derp IRC bot"
password = ""
# sasl_user = "account" # SASL PLAIN username (optional)
# sasl_pass = "secret" # SASL PLAIN password (optional)
# ircv3_caps = ["multi-prefix", "away-notify", "server-time"] # IRCv3 capabilities
[bot]
prefix = "!"

View File

@@ -68,6 +68,37 @@ admins = ["*!~user@trusted.host", "ops!*@*.ops.net"]
IRC operators are auto-detected via WHO. Hostmask patterns use fnmatch.
## Channel Management (admin)
```
!kick nick reason # Kick user from channel
!ban *!*@bad.host # Ban hostmask
!unban *!*@bad.host # Remove ban
!topic New topic text # Set channel topic
!topic # Query current topic
!mode +m # Set channel mode
!mode +o nick # Give ops
```
## State Store (admin)
```
!state list myplugin # List keys
!state get myplugin key # Get value
!state del myplugin key # Delete key
!state clear myplugin # Clear all keys
```
## IRCv3 Capabilities
```toml
# config/derp.toml
[server]
ircv3_caps = ["multi-prefix", "away-notify", "server-time"]
```
SASL auto-added when sasl_user/sasl_pass configured.
## Plugin Management (admin)
```
@@ -232,6 +263,7 @@ msg.is_channel # True if channel
msg.prefix # nick!user@host
msg.command # PRIVMSG, JOIN, etc.
msg.params # All params list
msg.tags # IRCv3 tags dict
```
## Config Locations

View File

@@ -35,6 +35,13 @@ realname = "derp IRC bot" # Real name field
password = "" # Server password (optional)
sasl_user = "" # SASL PLAIN username (optional)
sasl_pass = "" # SASL PLAIN password (optional)
ircv3_caps = [ # IRCv3 capabilities to request
"multi-prefix",
"away-notify",
"server-time",
"cap-notify",
"account-notify",
]
[bot]
prefix = "!" # Command prefix character
@@ -66,6 +73,12 @@ level = "info" # Logging level: debug, info, warning, error
| `!unload <plugin>` | Unload a plugin (admin) |
| `!admins` | Show admin patterns and detected opers (admin) |
| `!plugins` | List loaded plugins with handler counts |
| `!state <action> <plugin> [key]` | Inspect plugin state store (admin) |
| `!kick <nick> [reason]` | Kick user from channel (admin) |
| `!ban <mask>` | Ban a hostmask in channel (admin) |
| `!unban <mask>` | Remove a ban from channel (admin) |
| `!topic [text]` | Set or query channel topic (admin) |
| `!mode <mode> [args]` | Set channel mode (admin) |
| `!dns <target> [type]` | DNS lookup (A, AAAA, MX, NS, TXT, CNAME, PTR, SOA) |
| `!encode <fmt> <text>` | Encode text (b64, hex, url, rot13) |
| `!decode <fmt> <text>` | Decode text (b64, hex, url, rot13) |
@@ -156,7 +169,8 @@ are configured.
| `!whoami` | Show your hostmask and admin status |
| `!admins` | Show configured patterns and detected opers (admin) |
Admin-restricted commands: `!load`, `!reload`, `!unload`, `!admins`.
Admin-restricted commands: `!load`, `!reload`, `!unload`, `!admins`, `!state`,
`!kick`, `!ban`, `!unban`, `!topic`, `!mode`.
### Writing Admin Commands
@@ -166,6 +180,76 @@ async def cmd_dangerous(bot, message):
...
```
## IRCv3 Capability Negotiation
The bot negotiates IRCv3 capabilities using `CAP LS 302` during registration.
This enables richer features on servers that support them.
### Default Capabilities
```toml
[server]
ircv3_caps = ["multi-prefix", "away-notify", "server-time",
"cap-notify", "account-notify"]
```
| Capability | Purpose |
|------------|---------|
| `multi-prefix` | Better IRCOP/voice detection |
| `away-notify` | Receive AWAY status changes |
| `server-time` | Accurate message timestamps |
| `cap-notify` | Dynamic capability updates |
| `account-notify` | Account login/logout notices |
| `sasl` | Auto-added when SASL credentials configured |
The bot only requests caps the server advertises. SASL is automatically
included when `sasl_user` and `sasl_pass` are configured.
### Message Tags
IRCv3 message tags (`@key=value;...`) are parsed automatically and available
on the message object as `message.tags` (a `dict[str, str]`). Values are
unescaped per the IRCv3 spec.
## Channel Management
Channel management commands require admin privileges and must be used in a
channel (not DM).
```
!kick <nick> [reason] Kick a user from the channel
!ban <mask> Set +b on a hostmask (e.g. *!*@bad.host)
!unban <mask> Remove +b from a hostmask
!topic [text] Set topic (empty = query current topic)
!mode <mode> [args] Set raw MODE on the channel
```
The bot must have channel operator status for these commands to take effect.
## Plugin State Persistence
Plugins can persist key-value data across restarts via `bot.state`:
```python
bot.state.set("myplugin", "last_run", "2026-02-15")
value = bot.state.get("myplugin", "last_run")
bot.state.delete("myplugin", "last_run")
keys = bot.state.keys("myplugin")
bot.state.clear("myplugin")
```
Data is stored in `data/state.db` (SQLite). Each plugin gets its own
namespace so keys never collide.
### Inspection Commands (admin)
```
!state list <plugin> List keys for a plugin
!state get <plugin> <key> Get a value
!state del <plugin> <key> Delete a key
!state clear <plugin> Clear all state for a plugin
```
## Local Databases (Wave 3)
Several plugins rely on local data files in the `data/` directory. Use the
@@ -254,6 +338,10 @@ The `bot` object provides:
| `bot.join(channel)` | Join a channel |
| `bot.part(channel [, reason])` | Leave a channel |
| `bot.quit([reason])` | Disconnect from server |
| `bot.kick(channel, nick [, reason])` | Kick a user |
| `bot.mode(target, mode_str, *args)` | Set a mode |
| `bot.set_topic(channel, topic)` | Set channel topic |
| `bot.state` | Plugin state store (get/set/delete/keys/clear) |
The `message` object provides:
@@ -266,3 +354,4 @@ The `message` object provides:
| `message.text` | Trailing text content |
| `message.is_channel` | Whether target is a channel |
| `message.params` | All message parameters |
| `message.tags` | IRCv3 message tags (dict) |

81
plugins/chanmgmt.py Normal file
View File

@@ -0,0 +1,81 @@
"""Channel management: kick, ban, unban, topic, mode."""
from derp.plugin import command
def _require_channel(message):
"""Return True if the message originated in a channel."""
return message.is_channel
@command("kick", help="Kick a user: !kick <nick> [reason]", admin=True)
async def cmd_kick(bot, message):
"""Kick a user from the current channel."""
if not _require_channel(message):
await bot.reply(message, "Must be used in a channel")
return
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !kick <nick> [reason]")
return
nick = parts[1]
reason = parts[2] if len(parts) > 2 else ""
await bot.kick(message.target, nick, reason)
@command("ban", help="Ban a hostmask: !ban <mask>", admin=True)
async def cmd_ban(bot, message):
"""Set +b on a hostmask in the current channel."""
if not _require_channel(message):
await bot.reply(message, "Must be used in a channel")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !ban <mask>")
return
mask = parts[1]
await bot.mode(message.target, "+b", mask)
@command("unban", help="Remove a ban: !unban <mask>", admin=True)
async def cmd_unban(bot, message):
"""Remove +b from a hostmask in the current channel."""
if not _require_channel(message):
await bot.reply(message, "Must be used in a channel")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !unban <mask>")
return
mask = parts[1]
await bot.mode(message.target, "-b", mask)
@command("topic", help="Set or query channel topic: !topic [text]", admin=True)
async def cmd_topic(bot, message):
"""Set or query the channel topic."""
if not _require_channel(message):
await bot.reply(message, "Must be used in a channel")
return
parts = message.text.split(None, 1)
if len(parts) < 2:
# Query current topic
from derp.irc import format_msg
await bot.conn.send(format_msg("TOPIC", message.target))
else:
await bot.set_topic(message.target, parts[1])
@command("mode", help="Set channel mode: !mode <mode> [args]", admin=True)
async def cmd_mode(bot, message):
"""Set a mode on the current channel."""
if not _require_channel(message):
await bot.reply(message, "Must be used in a channel")
return
parts = message.text.split(None)
if len(parts) < 2:
await bot.reply(message, "Usage: !mode <mode> [args]")
return
mode_str = parts[1]
args = parts[2:]
await bot.mode(message.target, mode_str, *args)

View File

@@ -163,3 +163,57 @@ async def cmd_admins(bot, message):
else:
parts.append("Opers: (none)")
await bot.reply(message, " | ".join(parts))
@command("state", help="Inspect plugin state: !state <list|get|del|clear> ...", admin=True)
async def cmd_state(bot, message):
"""Manage the plugin state store.
Usage:
!state list <plugin> List keys
!state get <plugin> <key> Get a value
!state del <plugin> <key> Delete a key
!state clear <plugin> Clear all state
"""
parts = message.text.split()
if len(parts) < 3:
await bot.reply(message, "Usage: !state <list|get|del|clear> <plugin> [key]")
return
action = parts[1].lower()
plugin = parts[2]
if action == "list":
keys = bot.state.keys(plugin)
if keys:
await bot.reply(message, f"{plugin}: {', '.join(keys)}")
else:
await bot.reply(message, f"{plugin}: (no keys)")
elif action == "get":
if len(parts) < 4:
await bot.reply(message, "Usage: !state get <plugin> <key>")
return
key = parts[3]
value = bot.state.get(plugin, key)
if value is not None:
await bot.reply(message, f"{plugin}.{key} = {value}")
else:
await bot.reply(message, f"{plugin}.{key}: not set")
elif action == "del":
if len(parts) < 4:
await bot.reply(message, "Usage: !state del <plugin> <key>")
return
key = parts[3]
if bot.state.delete(plugin, key):
await bot.reply(message, f"Deleted {plugin}.{key}")
else:
await bot.reply(message, f"{plugin}.{key}: not found")
elif action == "clear":
count = bot.state.clear(plugin)
await bot.reply(message, f"Cleared {count} key(s) from {plugin}")
else:
await bot.reply(message, "Usage: !state <list|get|del|clear> <plugin> [key]")

View File

@@ -13,6 +13,7 @@ from pathlib import Path
from derp import __version__
from derp.irc import IRCConnection, Message, format_msg, parse
from derp.plugin import Handler, PluginRegistry
from derp.state import StateStore
log = logging.getLogger(__name__)
@@ -63,6 +64,8 @@ class Bot:
self._tasks: set[asyncio.Task] = set()
self._admins: list[str] = config.get("bot", {}).get("admins", [])
self._opers: set[str] = set() # hostmasks of known IRC operators
self._caps: set[str] = set() # negotiated IRCv3 caps
self.state = StateStore()
# Rate limiter: default 2 msg/sec, burst of 5
rate_cfg = config.get("bot", {})
self._bucket = _TokenBucket(
@@ -92,40 +95,86 @@ class Bot:
await self.conn.close()
async def _register(self) -> None:
"""Send NICK/USER registration, with optional SASL PLAIN."""
"""IRCv3 CAP negotiation followed by NICK/USER registration."""
srv = self.config["server"]
sasl_user = srv.get("sasl_user", "")
sasl_pass = srv.get("sasl_pass", "")
if sasl_user and sasl_pass:
await self._sasl_auth(sasl_user, sasl_pass)
# 1. Request server capabilities
await self.conn.send("CAP LS 302")
available = await self._cap_ls()
# 2. Determine desired caps
wanted = set(srv.get("ircv3_caps", [
"multi-prefix", "away-notify", "server-time",
"cap-notify", "account-notify",
]))
if srv.get("sasl_user") and srv.get("sasl_pass"):
wanted.add("sasl")
to_request = wanted & available
if to_request:
await self.conn.send(f"CAP REQ :{' '.join(sorted(to_request))}")
acked = await self._cap_ack()
self._caps = acked
log.info("negotiated caps: %s", " ".join(sorted(acked)))
else:
self._caps = set()
# 3. SASL auth if negotiated
if "sasl" in self._caps:
await self._sasl_auth(srv["sasl_user"], srv["sasl_pass"])
# 4. End capability negotiation
await self.conn.send("CAP END")
# 5. Standard registration
if srv.get("password"):
await self.conn.send(format_msg("PASS", srv["password"]))
await self.conn.send(format_msg("NICK", self.nick))
await self.conn.send(format_msg("USER", srv["user"], "0", "*", srv["realname"]))
async def _sasl_auth(self, user: str, password: str) -> None:
"""Perform SASL PLAIN authentication during registration."""
await self.conn.send("CAP REQ :sasl")
# Wait for CAP ACK or NAK
async def _cap_ls(self) -> set[str]:
"""Read CAP LS response(s). Returns set of capability names."""
caps: set[str] = set()
while True:
line = await self.conn.readline()
if line is None:
log.error("connection closed during SASL negotiation")
return
log.error("connection closed during CAP LS")
return caps
msg = parse(line)
if msg.command == "CAP" and len(msg.params) >= 3:
sub = msg.params[1].upper()
if sub == "ACK" and "sasl" in msg.params[-1].lower():
break
if sub == "NAK":
log.warning("server rejected SASL capability")
await self.conn.send("CAP END")
return
if sub == "LS":
# Multi-line: CAP * LS * :caps...
# Final: CAP * LS :caps...
cap_str = msg.params[-1]
for token in cap_str.split():
name = token.split("=", 1)[0]
caps.add(name)
# Check for continuation marker
if len(msg.params) >= 4 and msg.params[2] == "*":
continue
return caps
return caps # pragma: no cover
# Send AUTHENTICATE PLAIN
async def _cap_ack(self) -> set[str]:
"""Read CAP ACK/NAK response. Returns set of acknowledged caps."""
while True:
line = await self.conn.readline()
if line is None:
log.error("connection closed during CAP REQ")
return set()
msg = parse(line)
if msg.command == "CAP" and len(msg.params) >= 3:
sub = msg.params[1].upper()
if sub == "ACK":
return set(msg.params[-1].split())
if sub == "NAK":
log.warning("server rejected caps: %s", msg.params[-1])
return set()
return set() # pragma: no cover
async def _sasl_auth(self, user: str, password: str) -> None:
"""Perform SASL PLAIN authentication (within CAP negotiation)."""
await self.conn.send("AUTHENTICATE PLAIN")
# Wait for AUTHENTICATE +
@@ -152,11 +201,10 @@ class Bot:
log.info("SASL authentication successful")
break
if msg.command in ("904", "905", "906"):
log.error("SASL authentication failed: %s", msg.params[-1] if msg.params else "")
log.error("SASL authentication failed: %s",
msg.params[-1] if msg.params else "")
break
await self.conn.send("CAP END")
async def _loop(self) -> None:
"""Read and dispatch messages until disconnect."""
while self._running:
@@ -343,6 +391,21 @@ class Bot:
await self.conn.send(format_msg("QUIT", reason))
await self.conn.close()
async def kick(self, channel: str, nick: str, reason: str = "") -> None:
"""Kick a user from a channel."""
if reason:
await self.conn.send(format_msg("KICK", channel, nick, reason))
else:
await self.conn.send(format_msg("KICK", channel, nick))
async def mode(self, target: str, mode_str: str, *args: str) -> None:
"""Set a mode on a target (channel or nick)."""
await self.conn.send(format_msg("MODE", target, mode_str, *args))
async def set_topic(self, channel: str, topic: str) -> None:
"""Set the channel topic."""
await self.conn.send(format_msg("TOPIC", channel, topic))
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
"""Load plugins from the configured directory."""
if plugins_dir is None:

View File

@@ -16,6 +16,10 @@ DEFAULTS: dict = {
"password": "",
"sasl_user": "",
"sasl_pass": "",
"ircv3_caps": [
"multi-prefix", "away-notify", "server-time",
"cap-notify", "account-notify",
],
},
"bot": {
"prefix": "!",

View File

@@ -10,15 +10,56 @@ from dataclasses import dataclass
log = logging.getLogger(__name__)
def _unescape_tag_value(value: str) -> str:
"""Unescape an IRCv3 message tag value per the spec."""
out: list[str] = []
i = 0
while i < len(value):
if value[i] == "\\" and i + 1 < len(value):
nxt = value[i + 1]
if nxt == ":":
out.append(";")
elif nxt == "s":
out.append(" ")
elif nxt == "\\":
out.append("\\")
elif nxt == "r":
out.append("\r")
elif nxt == "n":
out.append("\n")
else:
out.append(nxt)
i += 2
else:
out.append(value[i])
i += 1
return "".join(out)
def _parse_tags(raw_tags: str) -> dict[str, str]:
"""Parse an IRCv3 tags string into a dict."""
tags: dict[str, str] = {}
for part in raw_tags.split(";"):
if not part:
continue
if "=" in part:
key, value = part.split("=", 1)
tags[key] = _unescape_tag_value(value)
else:
tags[part] = ""
return tags
@dataclass(slots=True)
class Message:
"""Parsed IRC message (RFC 1459)."""
"""Parsed IRC message (RFC 1459 + IRCv3 tags)."""
raw: str
prefix: str | None
nick: str | None
command: str
params: list[str]
tags: dict[str, str]
@property
def target(self) -> str | None:
@@ -39,11 +80,17 @@ class Message:
def parse(line: str) -> Message:
"""Parse a raw IRC line into a Message.
Format: [:prefix] command [params...] [:trailing]
Format: [@tags] [:prefix] command [params...] [:trailing]
"""
raw = line
prefix = None
nick = None
tags: dict[str, str] = {}
# IRCv3 message tags
if line.startswith("@"):
tag_str, line = line[1:].split(" ", 1)
tags = _parse_tags(tag_str)
if line.startswith(":"):
prefix, line = line[1:].split(" ", 1)
@@ -63,7 +110,10 @@ def parse(line: str) -> Message:
if trailing is not None:
params.append(trailing)
return Message(raw=raw, prefix=prefix, nick=nick, command=command, params=params)
return Message(
raw=raw, prefix=prefix, nick=nick, command=command,
params=params, tags=tags,
)
def format_msg(command: str, *params: str) -> str:

89
src/derp/state.py Normal file
View File

@@ -0,0 +1,89 @@
"""SQLite key-value store for plugin state persistence."""
from __future__ import annotations
import logging
import sqlite3
from pathlib import Path
log = logging.getLogger(__name__)
_SCHEMA = """\
CREATE TABLE IF NOT EXISTS state (
plugin TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (plugin, key)
);
"""
class StateStore:
"""Persistent key-value store backed by SQLite.
Each plugin gets its own namespace (plugin, key) so keys never collide
across plugins. The database is created lazily in ``data/state.db``.
"""
def __init__(self, db_path: str | Path = "data/state.db") -> None:
self._path = Path(db_path)
self._conn: sqlite3.Connection | None = None
def _db(self) -> sqlite3.Connection:
"""Return (and lazily create) the database connection."""
if self._conn is None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(str(self._path))
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
log.debug("state store opened: %s", self._path)
return self._conn
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
"""Get a value by plugin and key."""
row = self._db().execute(
"SELECT value FROM state WHERE plugin = ? AND key = ?",
(plugin, key),
).fetchone()
return row[0] if row else default
def set(self, plugin: str, key: str, value: str) -> None:
"""Set a value, creating or updating as needed."""
db = self._db()
db.execute(
"INSERT INTO state (plugin, key, value) VALUES (?, ?, ?)"
" ON CONFLICT(plugin, key) DO UPDATE SET value = excluded.value",
(plugin, key, value),
)
db.commit()
def delete(self, plugin: str, key: str) -> bool:
"""Delete a key. Returns True if a row was removed."""
db = self._db()
cur = db.execute(
"DELETE FROM state WHERE plugin = ? AND key = ?",
(plugin, key),
)
db.commit()
return cur.rowcount > 0
def keys(self, plugin: str) -> list[str]:
"""List all keys for a plugin."""
rows = self._db().execute(
"SELECT key FROM state WHERE plugin = ? ORDER BY key",
(plugin,),
).fetchall()
return [r[0] for r in rows]
def clear(self, plugin: str) -> int:
"""Delete all state for a plugin. Returns number of rows removed."""
db = self._db()
cur = db.execute("DELETE FROM state WHERE plugin = ?", (plugin,))
db.commit()
return cur.rowcount
def close(self) -> None:
"""Close the database connection."""
if self._conn is not None:
self._conn.close()
self._conn = None

View File

@@ -1,6 +1,6 @@
"""Tests for IRC message parsing and formatting."""
from derp.irc import format_msg, parse
from derp.irc import _parse_tags, _unescape_tag_value, format_msg, parse
class TestParse:
@@ -75,6 +75,36 @@ class TestParse:
assert msg.target == "#channel"
assert msg.text == "leaving"
def test_no_tags_default(self):
msg = parse(":nick!u@h PRIVMSG #ch :hello")
assert msg.tags == {}
def test_tags_parsed(self):
msg = parse("@time=2026-02-15T12:00:00Z;account=alice "
":nick!user@host PRIVMSG #chan :hello")
assert msg.tags == {"time": "2026-02-15T12:00:00Z", "account": "alice"}
assert msg.nick == "nick"
assert msg.command == "PRIVMSG"
assert msg.text == "hello"
def test_tags_value_unescaping(self):
msg = parse(r"@key=hello\sworld\:end :nick!u@h PRIVMSG #ch :test")
assert msg.tags["key"] == "hello world;end"
def test_tags_no_value(self):
msg = parse("@draft/feature :nick!u@h PRIVMSG #ch :test")
assert msg.tags == {"draft/feature": ""}
def test_tags_backslash_escapes(self):
assert _unescape_tag_value(r"a\\b\r\n") == "a\\b\r\n"
def test_tags_mixed_keys(self):
tags = _parse_tags("a=1;b;c=three")
assert tags == {"a": "1", "b": "", "c": "three"}
def test_tags_empty_string(self):
assert _parse_tags("") == {}
class TestFormat:
"""IRC message formatting tests."""

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from derp.bot import _AMBIGUOUS, Bot
from derp.irc import Message
from derp.plugin import PluginRegistry, command, event
from derp.state import StateStore
class TestDecorators:
@@ -445,11 +446,11 @@ class TestIsAdmin:
def _msg(prefix: str) -> Message:
"""Create a minimal Message with a given prefix."""
return Message(raw="", prefix=prefix, nick=prefix.split("!")[0],
command="PRIVMSG", params=["#test", "!test"])
command="PRIVMSG", params=["#test", "!test"], tags={})
def test_no_prefix_not_admin(self):
bot = self._make_bot()
msg = Message(raw="", prefix=None, nick=None, command="PRIVMSG", params=[])
msg = Message(raw="", prefix=None, nick=None, command="PRIVMSG", params=[], tags={})
assert bot._is_admin(msg) is False
def test_oper_is_admin(self):
@@ -476,3 +477,71 @@ class TestIsAdmin:
bot = self._make_bot()
msg = self._msg("nobody!~user@host")
assert bot._is_admin(msg) is False
class TestStateStore:
"""Test the SQLite key-value state store."""
def test_get_missing_returns_default(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
assert store.get("plug", "key") is None
assert store.get("plug", "key", "fallback") == "fallback"
def test_set_and_get(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("plug", "color", "blue")
assert store.get("plug", "color") == "blue"
def test_set_overwrite(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("plug", "val", "one")
store.set("plug", "val", "two")
assert store.get("plug", "val") == "two"
def test_namespace_isolation(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("alpha", "key", "a")
store.set("beta", "key", "b")
assert store.get("alpha", "key") == "a"
assert store.get("beta", "key") == "b"
def test_delete_existing(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("plug", "key", "val")
assert store.delete("plug", "key") is True
assert store.get("plug", "key") is None
def test_delete_missing(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
assert store.delete("plug", "ghost") is False
def test_keys(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("plug", "b", "2")
store.set("plug", "a", "1")
store.set("other", "c", "3")
assert store.keys("plug") == ["a", "b"]
assert store.keys("other") == ["c"]
assert store.keys("empty") == []
def test_clear(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
store.set("plug", "a", "1")
store.set("plug", "b", "2")
store.set("other", "c", "3")
count = store.clear("plug")
assert count == 2
assert store.keys("plug") == []
assert store.keys("other") == ["c"]
def test_clear_empty(self, tmp_path: Path):
store = StateStore(tmp_path / "state.db")
assert store.clear("empty") == 0
def test_close_and_reopen(self, tmp_path: Path):
db_path = tmp_path / "state.db"
store = StateStore(db_path)
store.set("plug", "persist", "yes")
store.close()
store2 = StateStore(db_path)
assert store2.get("plug", "persist") == "yes"