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

@@ -21,15 +21,13 @@ from plugins.rss import ( # noqa: E402
_MAX_SEEN,
_delete,
_derive_name,
_errors,
_feeds,
_load,
_parse_atom,
_parse_date,
_parse_feed,
_parse_rss,
_poll_once,
_pollers,
_ps,
_restore,
_save,
_start_poller,
@@ -158,6 +156,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.state = _FakeState()
self._pstate: dict = {}
self._admin = admin
async def send(self, target: str, text: str) -> None:
@@ -190,13 +189,7 @@ def _pm(text: str, nick: str = "alice") -> Message:
def _clear() -> None:
"""Reset module-level state between tests."""
for task in _pollers.values():
if task and not task.done():
task.cancel()
_pollers.clear()
_feeds.clear()
_errors.clear()
"""No-op -- state is per-bot now, each _FakeBot starts fresh."""
def _fake_fetch_ok(url, etag="", last_modified=""):
@@ -512,8 +505,8 @@ class TestCmdRssAdd:
assert data["name"] == "testfeed"
assert data["channel"] == "#test"
assert len(data["seen"]) == 3
assert "#test:testfeed" in _pollers
_stop_poller("#test:testfeed")
assert "#test:testfeed" in _ps(bot)["pollers"]
_stop_poller(bot, "#test:testfeed")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -527,7 +520,7 @@ class TestCmdRssAdd:
await cmd_rss(bot, _msg("!rss add https://hnrss.org/newest"))
await asyncio.sleep(0)
assert "Subscribed 'hnrss'" in bot.replied[0]
_stop_poller("#test:hnrss")
_stop_poller(bot, "#test:hnrss")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -562,7 +555,7 @@ class TestCmdRssAdd:
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
await cmd_rss(bot, _msg("!rss add https://other.com/feed myfeed"))
assert "already exists" in bot.replied[0]
_stop_poller("#test:myfeed")
_stop_poller(bot, "#test:myfeed")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -608,7 +601,7 @@ class TestCmdRssAdd:
await asyncio.sleep(0)
data = _load(bot, "#test:test")
assert data["url"] == "https://example.com/feed"
_stop_poller("#test:test")
_stop_poller(bot, "#test:test")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -631,7 +624,7 @@ class TestCmdRssDel:
await cmd_rss(bot, _msg("!rss del delfeed"))
assert "Unsubscribed 'delfeed'" in bot.replied[0]
assert _load(bot, "#test:delfeed") is None
assert "#test:delfeed" not in _pollers
assert "#test:delfeed" not in _ps(bot)["pollers"]
await asyncio.sleep(0)
asyncio.run(inner())
@@ -819,7 +812,7 @@ class TestPollOnce:
}
key = "#test:f304"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_304):
@@ -839,13 +832,13 @@ class TestPollOnce:
}
key = "#test:ferr"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_error):
await _poll_once(bot, key)
await _poll_once(bot, key)
assert _errors[key] == 2
assert _ps(bot)["errors"][key] == 2
updated = _load(bot, key)
assert updated["last_error"] == "Connection refused"
@@ -880,7 +873,7 @@ class TestPollOnce:
}
key = "#test:big"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", fake_big):
@@ -902,7 +895,7 @@ class TestPollOnce:
}
key = "#test:quiet"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -926,7 +919,7 @@ class TestPollOnce:
}
key = "#test:etag"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
with patch.object(_mod, "_fetch_feed", _fake_fetch_ok):
@@ -954,10 +947,10 @@ class TestRestore:
async def inner():
_restore(bot)
assert "#test:restored" in _pollers
task = _pollers["#test:restored"]
assert "#test:restored" in _ps(bot)["pollers"]
task = _ps(bot)["pollers"]["#test:restored"]
assert not task.done()
_stop_poller("#test:restored")
_stop_poller(bot, "#test:restored")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -975,10 +968,10 @@ class TestRestore:
async def inner():
# Pre-place an active task
dummy = asyncio.create_task(asyncio.sleep(9999))
_pollers["#test:active"] = dummy
_ps(bot)["pollers"]["#test:active"] = dummy
_restore(bot)
# Should not have replaced it
assert _pollers["#test:active"] is dummy
assert _ps(bot)["pollers"]["#test:active"] is dummy
dummy.cancel()
await asyncio.sleep(0)
@@ -998,13 +991,13 @@ class TestRestore:
# Place a completed task
done_task = asyncio.create_task(asyncio.sleep(0))
await done_task
_pollers["#test:done"] = done_task
_ps(bot)["pollers"]["#test:done"] = done_task
_restore(bot)
# Should have been replaced
new_task = _pollers["#test:done"]
new_task = _ps(bot)["pollers"]["#test:done"]
assert new_task is not done_task
assert not new_task.done()
_stop_poller("#test:done")
_stop_poller(bot, "#test:done")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -1016,7 +1009,7 @@ class TestRestore:
async def inner():
_restore(bot)
assert "#test:bad" not in _pollers
assert "#test:bad" not in _ps(bot)["pollers"]
asyncio.run(inner())
@@ -1033,8 +1026,8 @@ class TestRestore:
async def inner():
msg = _msg("", target="botname")
await on_connect(bot, msg)
assert "#test:conn" in _pollers
_stop_poller("#test:conn")
assert "#test:conn" in _ps(bot)["pollers"]
_stop_poller(bot, "#test:conn")
await asyncio.sleep(0)
asyncio.run(inner())
@@ -1055,16 +1048,17 @@ class TestPollerManagement:
}
key = "#test:mgmt"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
_start_poller(bot, key)
assert key in _pollers
assert not _pollers[key].done()
_stop_poller(key)
ps = _ps(bot)
assert key in ps["pollers"]
assert not ps["pollers"][key].done()
_stop_poller(bot, key)
await asyncio.sleep(0)
assert key not in _pollers
assert key not in _feeds
assert key not in ps["pollers"]
assert key not in ps["feeds"]
asyncio.run(inner())
@@ -1078,22 +1072,24 @@ class TestPollerManagement:
}
key = "#test:idem"
_save(bot, key, data)
_feeds[key] = data
_ps(bot)["feeds"][key] = data
async def inner():
_start_poller(bot, key)
first = _pollers[key]
ps = _ps(bot)
first = ps["pollers"][key]
_start_poller(bot, key)
assert _pollers[key] is first
_stop_poller(key)
assert ps["pollers"][key] is first
_stop_poller(bot, key)
await asyncio.sleep(0)
asyncio.run(inner())
def test_stop_nonexistent(self):
_clear()
bot = _FakeBot()
# Should not raise
_stop_poller("#test:nonexistent")
_stop_poller(bot, "#test:nonexistent")
# ---------------------------------------------------------------------------