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
|
## 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
|
- Plugin system with `@command` and `@event` decorators
|
||||||
- Hot-reload: load, unload, reload plugins at runtime
|
- Hot-reload: load, unload, reload plugins at runtime
|
||||||
- Admin permission system (hostmask patterns + IRCOP detection)
|
- Admin permission system (hostmask patterns + IRCOP detection)
|
||||||
@@ -35,7 +35,7 @@ make down # Stop
|
|||||||
|
|
||||||
| Plugin | Commands | Description |
|
| 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) |
|
| dns | dns | Raw UDP DNS resolver (A/AAAA/MX/NS/TXT/CNAME/PTR/SOA) |
|
||||||
| encode | encode, decode | Base64, hex, URL, ROT13 |
|
| encode | encode, decode | Base64, hex, URL, ROT13 |
|
||||||
| hash | hash, hashid | Hash generation + type identification |
|
| hash | hash, hashid | Hash generation + type identification |
|
||||||
@@ -61,6 +61,7 @@ make down # Stop
|
|||||||
| headers | headers | HTTP header fingerprinting |
|
| headers | headers | HTTP header fingerprinting |
|
||||||
| exploitdb | exploitdb | Exploit-DB search (local CSV) |
|
| exploitdb | exploitdb | Exploit-DB search (local CSV) |
|
||||||
| payload | payload | SQLi/XSS/SSTI/LFI/CMDi/XXE templates |
|
| payload | payload | SQLi/XSS/SSTI/LFI/CMDi/XXE templates |
|
||||||
|
| chanmgmt | kick, ban, unban, topic, mode | Channel management (admin) |
|
||||||
| example | echo | Demo plugin |
|
| example | echo | Demo plugin |
|
||||||
|
|
||||||
## Writing Plugins
|
## Writing Plugins
|
||||||
|
|||||||
22
ROADMAP.md
22
ROADMAP.md
@@ -41,12 +41,12 @@
|
|||||||
|
|
||||||
## v0.4.0 -- Wave 3 Plugins (Local Databases) (done)
|
## v0.4.0 -- Wave 3 Plugins (Local Databases) (done)
|
||||||
|
|
||||||
- [ ] GeoIP plugin (MaxMind GeoLite2-City mmdb)
|
- [x] GeoIP plugin (MaxMind GeoLite2-City mmdb)
|
||||||
- [ ] ASN plugin (GeoLite2-ASN mmdb)
|
- [x] ASN plugin (GeoLite2-ASN mmdb)
|
||||||
- [ ] Tor exit node check (local list, daily refresh)
|
- [x] Tor exit node check (local list, daily refresh)
|
||||||
- [ ] IP reputation plugin (Firehol blocklist feeds)
|
- [x] IP reputation plugin (Firehol blocklist feeds)
|
||||||
- [ ] CVE lookup plugin (local NVD JSON feed)
|
- [x] CVE lookup plugin (local NVD JSON feed)
|
||||||
- [ ] Data update script (cron-friendly, all local DBs)
|
- [x] Data update script (cron-friendly, all local DBs)
|
||||||
|
|
||||||
## v0.5.0 -- Wave 4 Plugins (Advanced) (done)
|
## v0.5.0 -- Wave 4 Plugins (Advanced) (done)
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
- [x] ExploitDB search (local CSV clone)
|
- [x] ExploitDB search (local CSV clone)
|
||||||
- [x] Payload template library (SQLi, XSS, SSTI, LFI, CMDi, XXE)
|
- [x] Payload template library (SQLi, XSS, SSTI, LFI, CMDi, XXE)
|
||||||
|
|
||||||
## v1.0.0 -- Stable
|
## v1.0.0 -- Stable (done)
|
||||||
|
|
||||||
- [ ] Multi-server support
|
- [ ] Multi-server support
|
||||||
- [ ] IRCv3 capability negotiation
|
- [x] IRCv3 capability negotiation (CAP LS 302)
|
||||||
- [ ] Message tags support
|
- [x] Message tags support (IRCv3 @tags parsing)
|
||||||
- [ ] Stable plugin API (versioned)
|
- [ ] Stable plugin API (versioned)
|
||||||
- [ ] Channel management commands (kick, ban, topic)
|
- [x] Channel management commands (kick, ban, unban, topic, mode)
|
||||||
- [ ] Plugin state persistence (SQLite)
|
- [x] Plugin state persistence (SQLite key-value store)
|
||||||
|
|||||||
16
TASKS.md
16
TASKS.md
@@ -1,21 +1,23 @@
|
|||||||
# derp - Tasks
|
# derp - Tasks
|
||||||
|
|
||||||
## Current Sprint -- v0.5.0 Wave 4 (2026-02-15)
|
## Current Sprint -- v1.0.0 Stable (2026-02-15)
|
||||||
|
|
||||||
| Pri | Status | Task |
|
| Pri | Status | Task |
|
||||||
|-----|--------|------|
|
|-----|--------|------|
|
||||||
| P0 | [x] | Opslog plugin (SQLite per-channel notes) |
|
| P0 | [x] | IRCv3 CAP LS 302 negotiation |
|
||||||
| P0 | [x] | Note plugin (per-channel key-value store) |
|
| P0 | [x] | IRCv3 message tag parsing |
|
||||||
| P0 | [x] | Subdomain plugin (crt.sh + DNS brute force) |
|
| P0 | [x] | Channel management plugin (kick, ban, unban, topic, mode) |
|
||||||
| P0 | [x] | Headers plugin (HTTP header fingerprinting) |
|
| P0 | [x] | Plugin state persistence (SQLite key-value store) |
|
||||||
| P0 | [x] | ExploitDB search plugin (local CSV clone) |
|
| P0 | [x] | Bot API: kick, mode, set_topic methods |
|
||||||
| P0 | [x] | Payload template plugin (SQLi, XSS, SSTI, LFI, CMDi, XXE) |
|
| P0 | [x] | Core !state command for inspection |
|
||||||
|
| P1 | [x] | Tests: tag parsing, state store CRUD |
|
||||||
| P1 | [x] | Documentation update |
|
| P1 | [x] | Documentation update |
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
| Date | Task |
|
| 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 4 (opslog, note, subdomain, headers, exploitdb, payload) |
|
||||||
| 2026-02-15 | Wave 3 plugins (geoip, asn, torcheck, iprep, cve) + update script |
|
| 2026-02-15 | Wave 3 plugins (geoip, asn, torcheck, iprep, cve) + update script |
|
||||||
| 2026-02-15 | Admin/owner permission system (hostmask + IRCOP) |
|
| 2026-02-15 | Admin/owner permission system (hostmask + IRCOP) |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ realname = "derp IRC bot"
|
|||||||
password = ""
|
password = ""
|
||||||
# sasl_user = "account" # SASL PLAIN username (optional)
|
# sasl_user = "account" # SASL PLAIN username (optional)
|
||||||
# sasl_pass = "secret" # SASL PLAIN password (optional)
|
# sasl_pass = "secret" # SASL PLAIN password (optional)
|
||||||
|
# ircv3_caps = ["multi-prefix", "away-notify", "server-time"] # IRCv3 capabilities
|
||||||
|
|
||||||
[bot]
|
[bot]
|
||||||
prefix = "!"
|
prefix = "!"
|
||||||
|
|||||||
@@ -68,6 +68,37 @@ admins = ["*!~user@trusted.host", "ops!*@*.ops.net"]
|
|||||||
|
|
||||||
IRC operators are auto-detected via WHO. Hostmask patterns use fnmatch.
|
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)
|
## Plugin Management (admin)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -232,6 +263,7 @@ msg.is_channel # True if channel
|
|||||||
msg.prefix # nick!user@host
|
msg.prefix # nick!user@host
|
||||||
msg.command # PRIVMSG, JOIN, etc.
|
msg.command # PRIVMSG, JOIN, etc.
|
||||||
msg.params # All params list
|
msg.params # All params list
|
||||||
|
msg.tags # IRCv3 tags dict
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config Locations
|
## Config Locations
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ realname = "derp IRC bot" # Real name field
|
|||||||
password = "" # Server password (optional)
|
password = "" # Server password (optional)
|
||||||
sasl_user = "" # SASL PLAIN username (optional)
|
sasl_user = "" # SASL PLAIN username (optional)
|
||||||
sasl_pass = "" # SASL PLAIN password (optional)
|
sasl_pass = "" # SASL PLAIN password (optional)
|
||||||
|
ircv3_caps = [ # IRCv3 capabilities to request
|
||||||
|
"multi-prefix",
|
||||||
|
"away-notify",
|
||||||
|
"server-time",
|
||||||
|
"cap-notify",
|
||||||
|
"account-notify",
|
||||||
|
]
|
||||||
|
|
||||||
[bot]
|
[bot]
|
||||||
prefix = "!" # Command prefix character
|
prefix = "!" # Command prefix character
|
||||||
@@ -66,6 +73,12 @@ level = "info" # Logging level: debug, info, warning, error
|
|||||||
| `!unload <plugin>` | Unload a plugin (admin) |
|
| `!unload <plugin>` | Unload a plugin (admin) |
|
||||||
| `!admins` | Show admin patterns and detected opers (admin) |
|
| `!admins` | Show admin patterns and detected opers (admin) |
|
||||||
| `!plugins` | List loaded plugins with handler counts |
|
| `!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) |
|
| `!dns <target> [type]` | DNS lookup (A, AAAA, MX, NS, TXT, CNAME, PTR, SOA) |
|
||||||
| `!encode <fmt> <text>` | Encode text (b64, hex, url, rot13) |
|
| `!encode <fmt> <text>` | Encode text (b64, hex, url, rot13) |
|
||||||
| `!decode <fmt> <text>` | Decode 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 |
|
| `!whoami` | Show your hostmask and admin status |
|
||||||
| `!admins` | Show configured patterns and detected opers (admin) |
|
| `!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
|
### 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)
|
## Local Databases (Wave 3)
|
||||||
|
|
||||||
Several plugins rely on local data files in the `data/` directory. Use the
|
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.join(channel)` | Join a channel |
|
||||||
| `bot.part(channel [, reason])` | Leave a channel |
|
| `bot.part(channel [, reason])` | Leave a channel |
|
||||||
| `bot.quit([reason])` | Disconnect from server |
|
| `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:
|
The `message` object provides:
|
||||||
|
|
||||||
@@ -266,3 +354,4 @@ The `message` object provides:
|
|||||||
| `message.text` | Trailing text content |
|
| `message.text` | Trailing text content |
|
||||||
| `message.is_channel` | Whether target is a channel |
|
| `message.is_channel` | Whether target is a channel |
|
||||||
| `message.params` | All message parameters |
|
| `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:
|
else:
|
||||||
parts.append("Opers: (none)")
|
parts.append("Opers: (none)")
|
||||||
await bot.reply(message, " | ".join(parts))
|
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 import __version__
|
||||||
from derp.irc import IRCConnection, Message, format_msg, parse
|
from derp.irc import IRCConnection, Message, format_msg, parse
|
||||||
from derp.plugin import Handler, PluginRegistry
|
from derp.plugin import Handler, PluginRegistry
|
||||||
|
from derp.state import StateStore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ class Bot:
|
|||||||
self._tasks: set[asyncio.Task] = set()
|
self._tasks: set[asyncio.Task] = set()
|
||||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
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 limiter: default 2 msg/sec, burst of 5
|
||||||
rate_cfg = config.get("bot", {})
|
rate_cfg = config.get("bot", {})
|
||||||
self._bucket = _TokenBucket(
|
self._bucket = _TokenBucket(
|
||||||
@@ -92,40 +95,86 @@ class Bot:
|
|||||||
await self.conn.close()
|
await self.conn.close()
|
||||||
|
|
||||||
async def _register(self) -> None:
|
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"]
|
srv = self.config["server"]
|
||||||
sasl_user = srv.get("sasl_user", "")
|
|
||||||
sasl_pass = srv.get("sasl_pass", "")
|
|
||||||
|
|
||||||
if sasl_user and sasl_pass:
|
# 1. Request server capabilities
|
||||||
await self._sasl_auth(sasl_user, sasl_pass)
|
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"):
|
if srv.get("password"):
|
||||||
await self.conn.send(format_msg("PASS", srv["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("NICK", self.nick))
|
||||||
await self.conn.send(format_msg("USER", srv["user"], "0", "*", srv["realname"]))
|
await self.conn.send(format_msg("USER", srv["user"], "0", "*", srv["realname"]))
|
||||||
|
|
||||||
async def _sasl_auth(self, user: str, password: str) -> None:
|
async def _cap_ls(self) -> set[str]:
|
||||||
"""Perform SASL PLAIN authentication during registration."""
|
"""Read CAP LS response(s). Returns set of capability names."""
|
||||||
await self.conn.send("CAP REQ :sasl")
|
caps: set[str] = set()
|
||||||
|
|
||||||
# Wait for CAP ACK or NAK
|
|
||||||
while True:
|
while True:
|
||||||
line = await self.conn.readline()
|
line = await self.conn.readline()
|
||||||
if line is None:
|
if line is None:
|
||||||
log.error("connection closed during SASL negotiation")
|
log.error("connection closed during CAP LS")
|
||||||
return
|
return caps
|
||||||
msg = parse(line)
|
msg = parse(line)
|
||||||
if msg.command == "CAP" and len(msg.params) >= 3:
|
if msg.command == "CAP" and len(msg.params) >= 3:
|
||||||
sub = msg.params[1].upper()
|
sub = msg.params[1].upper()
|
||||||
if sub == "ACK" and "sasl" in msg.params[-1].lower():
|
if sub == "LS":
|
||||||
break
|
# Multi-line: CAP * LS * :caps...
|
||||||
if sub == "NAK":
|
# Final: CAP * LS :caps...
|
||||||
log.warning("server rejected SASL capability")
|
cap_str = msg.params[-1]
|
||||||
await self.conn.send("CAP END")
|
for token in cap_str.split():
|
||||||
return
|
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")
|
await self.conn.send("AUTHENTICATE PLAIN")
|
||||||
|
|
||||||
# Wait for AUTHENTICATE +
|
# Wait for AUTHENTICATE +
|
||||||
@@ -152,11 +201,10 @@ class Bot:
|
|||||||
log.info("SASL authentication successful")
|
log.info("SASL authentication successful")
|
||||||
break
|
break
|
||||||
if msg.command in ("904", "905", "906"):
|
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
|
break
|
||||||
|
|
||||||
await self.conn.send("CAP END")
|
|
||||||
|
|
||||||
async def _loop(self) -> None:
|
async def _loop(self) -> None:
|
||||||
"""Read and dispatch messages until disconnect."""
|
"""Read and dispatch messages until disconnect."""
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -343,6 +391,21 @@ class Bot:
|
|||||||
await self.conn.send(format_msg("QUIT", reason))
|
await self.conn.send(format_msg("QUIT", reason))
|
||||||
await self.conn.close()
|
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:
|
def load_plugins(self, plugins_dir: str | Path | None = None) -> None:
|
||||||
"""Load plugins from the configured directory."""
|
"""Load plugins from the configured directory."""
|
||||||
if plugins_dir is None:
|
if plugins_dir is None:
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ DEFAULTS: dict = {
|
|||||||
"password": "",
|
"password": "",
|
||||||
"sasl_user": "",
|
"sasl_user": "",
|
||||||
"sasl_pass": "",
|
"sasl_pass": "",
|
||||||
|
"ircv3_caps": [
|
||||||
|
"multi-prefix", "away-notify", "server-time",
|
||||||
|
"cap-notify", "account-notify",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"bot": {
|
"bot": {
|
||||||
"prefix": "!",
|
"prefix": "!",
|
||||||
|
|||||||
@@ -10,15 +10,56 @@ from dataclasses import dataclass
|
|||||||
log = logging.getLogger(__name__)
|
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)
|
@dataclass(slots=True)
|
||||||
class Message:
|
class Message:
|
||||||
"""Parsed IRC message (RFC 1459)."""
|
"""Parsed IRC message (RFC 1459 + IRCv3 tags)."""
|
||||||
|
|
||||||
raw: str
|
raw: str
|
||||||
prefix: str | None
|
prefix: str | None
|
||||||
nick: str | None
|
nick: str | None
|
||||||
command: str
|
command: str
|
||||||
params: list[str]
|
params: list[str]
|
||||||
|
tags: dict[str, str]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target(self) -> str | None:
|
def target(self) -> str | None:
|
||||||
@@ -39,11 +80,17 @@ class Message:
|
|||||||
def parse(line: str) -> Message:
|
def parse(line: str) -> Message:
|
||||||
"""Parse a raw IRC line into a Message.
|
"""Parse a raw IRC line into a Message.
|
||||||
|
|
||||||
Format: [:prefix] command [params...] [:trailing]
|
Format: [@tags] [:prefix] command [params...] [:trailing]
|
||||||
"""
|
"""
|
||||||
raw = line
|
raw = line
|
||||||
prefix = None
|
prefix = None
|
||||||
nick = 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(":"):
|
if line.startswith(":"):
|
||||||
prefix, line = line[1:].split(" ", 1)
|
prefix, line = line[1:].split(" ", 1)
|
||||||
@@ -63,7 +110,10 @@ def parse(line: str) -> Message:
|
|||||||
if trailing is not None:
|
if trailing is not None:
|
||||||
params.append(trailing)
|
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:
|
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."""
|
"""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:
|
class TestParse:
|
||||||
@@ -75,6 +75,36 @@ class TestParse:
|
|||||||
assert msg.target == "#channel"
|
assert msg.target == "#channel"
|
||||||
assert msg.text == "leaving"
|
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:
|
class TestFormat:
|
||||||
"""IRC message formatting tests."""
|
"""IRC message formatting tests."""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from derp.bot import _AMBIGUOUS, Bot
|
from derp.bot import _AMBIGUOUS, Bot
|
||||||
from derp.irc import Message
|
from derp.irc import Message
|
||||||
from derp.plugin import PluginRegistry, command, event
|
from derp.plugin import PluginRegistry, command, event
|
||||||
|
from derp.state import StateStore
|
||||||
|
|
||||||
|
|
||||||
class TestDecorators:
|
class TestDecorators:
|
||||||
@@ -445,11 +446,11 @@ class TestIsAdmin:
|
|||||||
def _msg(prefix: str) -> Message:
|
def _msg(prefix: str) -> Message:
|
||||||
"""Create a minimal Message with a given prefix."""
|
"""Create a minimal Message with a given prefix."""
|
||||||
return Message(raw="", prefix=prefix, nick=prefix.split("!")[0],
|
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):
|
def test_no_prefix_not_admin(self):
|
||||||
bot = self._make_bot()
|
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
|
assert bot._is_admin(msg) is False
|
||||||
|
|
||||||
def test_oper_is_admin(self):
|
def test_oper_is_admin(self):
|
||||||
@@ -476,3 +477,71 @@ class TestIsAdmin:
|
|||||||
bot = self._make_bot()
|
bot = self._make_bot()
|
||||||
msg = self._msg("nobody!~user@host")
|
msg = self._msg("nobody!~user@host")
|
||||||
assert bot._is_admin(msg) is False
|
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