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

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

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

View File

@@ -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"