feat: add multi-server support

Connect to multiple IRC servers concurrently from a single config file.
Plugins are loaded once and shared; per-server state is isolated via
separate SQLite databases and per-bot runtime state (bot._pstate).

- Add build_server_configs() for [servers.*] config layout
- Bot.__init__ gains name parameter, _pstate dict for plugin isolation
- cli.py runs multiple bots via asyncio.gather
- 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern
- Backward compatible: legacy [server] config works unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 19:04:20 +01:00
parent e9528bd879
commit 073659607e
27 changed files with 987 additions and 735 deletions

View File

@@ -5,7 +5,14 @@ from pathlib import Path
import pytest
from derp.config import DEFAULTS, _merge, load, resolve_config
from derp.config import (
DEFAULTS,
_merge,
_server_name,
build_server_configs,
load,
resolve_config,
)
class TestMerge:
@@ -110,3 +117,166 @@ class TestResolveConfig:
original = copy.deepcopy(DEFAULTS)
resolve_config(None)
assert DEFAULTS == original
# ---------------------------------------------------------------------------
# _server_name
# ---------------------------------------------------------------------------
class TestServerName:
"""Test hostname-to-short-name derivation."""
def test_libera(self):
assert _server_name("irc.libera.chat") == "libera"
def test_oftc(self):
assert _server_name("irc.oftc.net") == "oftc"
def test_freenode(self):
assert _server_name("chat.freenode.net") == "freenode"
def test_plain_hostname(self):
assert _server_name("myserver") == "myserver"
def test_empty_fallback(self):
assert _server_name("") == ""
def test_only_common_parts(self):
assert _server_name("irc.chat.irc") == "irc.chat.irc"
# ---------------------------------------------------------------------------
# build_server_configs
# ---------------------------------------------------------------------------
class TestBuildServerConfigs:
"""Test multi-server config builder."""
def test_legacy_single_server(self):
"""Legacy [server] config returns a single-entry dict."""
raw = _merge(DEFAULTS, {
"server": {"host": "irc.libera.chat", "nick": "testbot"},
})
result = build_server_configs(raw)
assert list(result.keys()) == ["libera"]
assert result["libera"]["server"]["nick"] == "testbot"
def test_legacy_preserves_full_config(self):
"""Legacy mode passes through the entire config dict."""
raw = _merge(DEFAULTS, {
"server": {"host": "irc.oftc.net"},
"bot": {"prefix": "."},
})
result = build_server_configs(raw)
cfg = result["oftc"]
assert cfg["bot"]["prefix"] == "."
assert cfg["server"]["host"] == "irc.oftc.net"
def test_multi_server_creates_entries(self):
"""Multiple [servers.*] blocks produce multiple entries."""
raw = {
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
"servers": {
"libera": {"host": "irc.libera.chat", "port": 6697,
"nick": "derp", "channels": ["#test"]},
"oftc": {"host": "irc.oftc.net", "port": 6697,
"nick": "derpbot", "channels": ["#derp"]},
},
}
result = build_server_configs(raw)
assert set(result.keys()) == {"libera", "oftc"}
def test_multi_server_key_separation(self):
"""Server keys and bot keys are separated correctly."""
raw = {
"servers": {
"test": {
"host": "irc.test.net", "port": 6667, "tls": False,
"nick": "bot",
"prefix": ".", "channels": ["#a"],
"admins": ["*!*@admin"],
},
},
}
result = build_server_configs(raw)
cfg = result["test"]
# Server keys
assert cfg["server"]["host"] == "irc.test.net"
assert cfg["server"]["port"] == 6667
assert cfg["server"]["nick"] == "bot"
# Bot keys (overrides)
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["channels"] == ["#a"]
assert cfg["bot"]["admins"] == ["*!*@admin"]
def test_multi_server_inherits_shared_bot(self):
"""Per-server configs inherit shared [bot] defaults."""
raw = {
"bot": {"prefix": ".", "admins": ["*!*@global"]},
"servers": {
"s1": {"host": "irc.s1.net", "nick": "bot1"},
},
}
result = build_server_configs(raw)
cfg = result["s1"]
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["admins"] == ["*!*@global"]
def test_multi_server_overrides_shared_bot(self):
"""Per-server bot keys override shared [bot] values."""
raw = {
"bot": {"prefix": "!", "admins": ["*!*@global"]},
"servers": {
"s1": {"host": "irc.s1.net", "nick": "bot1",
"prefix": ".", "admins": ["*!*@local"]},
},
}
result = build_server_configs(raw)
cfg = result["s1"]
assert cfg["bot"]["prefix"] == "."
assert cfg["bot"]["admins"] == ["*!*@local"]
def test_multi_server_defaults_applied(self):
"""Missing keys fall back to DEFAULTS."""
raw = {
"servers": {
"minimal": {"host": "irc.min.net", "nick": "m"},
},
}
result = build_server_configs(raw)
cfg = result["minimal"]
assert cfg["server"]["tls"] is True # from DEFAULTS
assert cfg["bot"]["prefix"] == "!" # from DEFAULTS
assert cfg["bot"]["rate_limit"] == 2.0
def test_multi_server_shared_sections(self):
"""Shared webhook/logging sections propagate to all servers."""
raw = {
"webhook": {"enabled": True, "port": 9090},
"logging": {"format": "json"},
"servers": {
"a": {"host": "irc.a.net", "nick": "a"},
"b": {"host": "irc.b.net", "nick": "b"},
},
}
result = build_server_configs(raw)
for name in ("a", "b"):
assert result[name]["webhook"]["enabled"] is True
assert result[name]["webhook"]["port"] == 9090
assert result[name]["logging"]["format"] == "json"
def test_empty_servers_section_falls_back(self):
"""Empty [servers] section treated as legacy single-server."""
raw = _merge(DEFAULTS, {"servers": {}})
result = build_server_configs(raw)
assert len(result) == 1
def test_no_servers_key_is_legacy(self):
"""Config without [servers] is legacy single-server mode."""
raw = copy.deepcopy(DEFAULTS)
result = build_server_configs(raw)
assert len(result) == 1
name = list(result.keys())[0]
assert result[name] is raw