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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user