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:
@@ -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
|
||||
|
||||
22
ROADMAP.md
22
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)
|
||||
|
||||
16
TASKS.md
16
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) |
|
||||
|
||||
@@ -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 = "!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
81
plugins/chanmgmt.py
Normal 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)
|
||||
@@ -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]")
|
||||
|
||||
107
src/derp/bot.py
107
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:
|
||||
|
||||
@@ -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": "!",
|
||||
|
||||
@@ -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
89
src/derp/state.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user