diff --git a/README.md b/README.md index 28d6ccc..dd83a8e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 0887554..e8b15a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TASKS.md b/TASKS.md index c9ad1cd..ead25bd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) | diff --git a/config/derp.toml.example b/config/derp.toml.example index d4e2b9d..070d9b6 100644 --- a/config/derp.toml.example +++ b/config/derp.toml.example @@ -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 = "!" diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index bc68822..08cdef3 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index b3804dd..9ec5e7e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 ` | Unload a plugin (admin) | | `!admins` | Show admin patterns and detected opers (admin) | | `!plugins` | List loaded plugins with handler counts | +| `!state [key]` | Inspect plugin state store (admin) | +| `!kick [reason]` | Kick user from channel (admin) | +| `!ban ` | Ban a hostmask in channel (admin) | +| `!unban ` | Remove a ban from channel (admin) | +| `!topic [text]` | Set or query channel topic (admin) | +| `!mode [args]` | Set channel mode (admin) | | `!dns [type]` | DNS lookup (A, AAAA, MX, NS, TXT, CNAME, PTR, SOA) | | `!encode ` | Encode text (b64, hex, url, rot13) | | `!decode ` | 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 [reason] Kick a user from the channel +!ban Set +b on a hostmask (e.g. *!*@bad.host) +!unban Remove +b from a hostmask +!topic [text] Set topic (empty = query current topic) +!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 List keys for a plugin +!state get Get a value +!state del Delete a key +!state clear 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) | diff --git a/plugins/chanmgmt.py b/plugins/chanmgmt.py new file mode 100644 index 0000000..c6f25ba --- /dev/null +++ b/plugins/chanmgmt.py @@ -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 [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 [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 ", 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 ") + return + mask = parts[1] + await bot.mode(message.target, "+b", mask) + + +@command("unban", help="Remove a ban: !unban ", 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 ") + 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 [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 [args]") + return + mode_str = parts[1] + args = parts[2:] + await bot.mode(message.target, mode_str, *args) diff --git a/plugins/core.py b/plugins/core.py index 85e4816..138f32b 100644 --- a/plugins/core.py +++ b/plugins/core.py @@ -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 ...", admin=True) +async def cmd_state(bot, message): + """Manage the plugin state store. + + Usage: + !state list List keys + !state get Get a value + !state del Delete a key + !state clear Clear all state + """ + parts = message.text.split() + if len(parts) < 3: + await bot.reply(message, "Usage: !state [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 ") + 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 ") + 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 [key]") diff --git a/src/derp/bot.py b/src/derp/bot.py index f7d4788..780fe1b 100644 --- a/src/derp/bot.py +++ b/src/derp/bot.py @@ -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: diff --git a/src/derp/config.py b/src/derp/config.py index c484faf..fb81c75 100644 --- a/src/derp/config.py +++ b/src/derp/config.py @@ -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": "!", diff --git a/src/derp/irc.py b/src/derp/irc.py index de4bcc3..b950394 100644 --- a/src/derp/irc.py +++ b/src/derp/irc.py @@ -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: diff --git a/src/derp/state.py b/src/derp/state.py new file mode 100644 index 0000000..071bbf3 --- /dev/null +++ b/src/derp/state.py @@ -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 diff --git a/tests/test_irc.py b/tests/test_irc.py index d8ab22a..b67ee16 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -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.""" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e739f80..116e27a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -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"