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:
@@ -23,6 +23,7 @@ from plugins.webhook import ( # noqa: E402
|
||||
_MAX_BODY,
|
||||
_handle_request,
|
||||
_http_response,
|
||||
_ps,
|
||||
_verify_signature,
|
||||
cmd_webhook,
|
||||
on_connect,
|
||||
@@ -62,6 +63,7 @@ class _FakeBot:
|
||||
self.replied: list[str] = []
|
||||
self.actions: list[tuple[str, str]] = []
|
||||
self.state = _FakeState()
|
||||
self._pstate: dict = {}
|
||||
self._admin = admin
|
||||
self.prefix = "!"
|
||||
self.config = {
|
||||
@@ -301,14 +303,14 @@ class TestRequestHandler:
|
||||
|
||||
def test_counter_increments(self):
|
||||
bot = _FakeBot()
|
||||
# Reset counter
|
||||
_mod._request_count = 0
|
||||
ps = _ps(bot)
|
||||
ps["request_count"] = 0
|
||||
body = json.dumps({"channel": "#test", "text": "hi"}).encode()
|
||||
raw = _build_request("POST", body, {"Content-Length": str(len(body))})
|
||||
reader = _FakeReader(raw)
|
||||
writer = _FakeWriter()
|
||||
asyncio.run(_handle_request(reader, writer, bot, ""))
|
||||
assert _mod._request_count == 1
|
||||
assert ps["request_count"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -320,28 +322,23 @@ class TestServerLifecycle:
|
||||
def test_disabled_config(self):
|
||||
"""Server does not start when webhook is disabled."""
|
||||
bot = _FakeBot(webhook_cfg={"enabled": False})
|
||||
msg = _msg("", target="")
|
||||
msg = Message(raw="", prefix="", nick="", command="001",
|
||||
params=["test", "Welcome"], tags={})
|
||||
# Reset global state
|
||||
_mod._server = None
|
||||
asyncio.run(on_connect(bot, msg))
|
||||
assert _mod._server is None
|
||||
assert _ps(bot)["server"] is None
|
||||
|
||||
def test_duplicate_guard(self):
|
||||
"""Second on_connect does not create a second server."""
|
||||
sentinel = object()
|
||||
_mod._server = sentinel
|
||||
bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0})
|
||||
_ps(bot)["server"] = sentinel
|
||||
msg = Message(raw="", prefix="", nick="", command="001",
|
||||
params=["test", "Welcome"], tags={})
|
||||
asyncio.run(on_connect(bot, msg))
|
||||
assert _mod._server is sentinel
|
||||
_mod._server = None # cleanup
|
||||
assert _ps(bot)["server"] is sentinel
|
||||
|
||||
def test_on_connect_starts(self):
|
||||
"""on_connect starts the server when enabled."""
|
||||
_mod._server = None
|
||||
bot = _FakeBot(webhook_cfg={
|
||||
"enabled": True, "host": "127.0.0.1", "port": 0, "secret": "",
|
||||
})
|
||||
@@ -350,10 +347,10 @@ class TestServerLifecycle:
|
||||
|
||||
async def _run():
|
||||
await on_connect(bot, msg)
|
||||
assert _mod._server is not None
|
||||
_mod._server.close()
|
||||
await _mod._server.wait_closed()
|
||||
_mod._server = None
|
||||
ps = _ps(bot)
|
||||
assert ps["server"] is not None
|
||||
ps["server"].close()
|
||||
await ps["server"].wait_closed()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -366,26 +363,25 @@ class TestServerLifecycle:
|
||||
class TestWebhookCommand:
|
||||
def test_not_running(self):
|
||||
bot = _FakeBot()
|
||||
_mod._server = None
|
||||
asyncio.run(cmd_webhook(bot, _msg("!webhook")))
|
||||
assert any("not running" in r for r in bot.replied)
|
||||
|
||||
def test_running_shows_status(self):
|
||||
bot = _FakeBot()
|
||||
_mod._request_count = 42
|
||||
_mod._started = time.monotonic() - 90 # 1m 30s ago
|
||||
ps = _ps(bot)
|
||||
ps["request_count"] = 42
|
||||
ps["started"] = time.monotonic() - 90 # 1m 30s ago
|
||||
|
||||
async def _run():
|
||||
# Start a real server on port 0 to get a valid socket
|
||||
srv = await asyncio.start_server(lambda r, w: None,
|
||||
"127.0.0.1", 0)
|
||||
_mod._server = srv
|
||||
ps["server"] = srv
|
||||
try:
|
||||
await cmd_webhook(bot, _msg("!webhook"))
|
||||
finally:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
_mod._server = None
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(bot.replied) == 1
|
||||
|
||||
Reference in New Issue
Block a user