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

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

View File

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