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:
@@ -21,11 +21,10 @@ from plugins.alert import ( # noqa: E402
|
||||
_MAX_SEEN,
|
||||
_compact_num,
|
||||
_delete,
|
||||
_errors,
|
||||
_extract_videos,
|
||||
_load,
|
||||
_poll_once,
|
||||
_pollers,
|
||||
_ps,
|
||||
_restore,
|
||||
_save,
|
||||
_save_result,
|
||||
@@ -35,7 +34,6 @@ from plugins.alert import ( # noqa: E402
|
||||
_start_poller,
|
||||
_state_key,
|
||||
_stop_poller,
|
||||
_subscriptions,
|
||||
_truncate,
|
||||
_validate_name,
|
||||
cmd_alert,
|
||||
@@ -169,6 +167,7 @@ class _FakeBot:
|
||||
self.actions: list[tuple[str, str]] = []
|
||||
self.replied: list[str] = []
|
||||
self.state = _FakeState()
|
||||
self._pstate: dict = {}
|
||||
self.registry = _FakeRegistry()
|
||||
self._admin = admin
|
||||
|
||||
@@ -205,14 +204,18 @@ def _pm(text: str, nick: str = "alice") -> Message:
|
||||
)
|
||||
|
||||
|
||||
def _clear() -> None:
|
||||
"""Reset module-level state between tests."""
|
||||
for task in _pollers.values():
|
||||
def _clear(bot=None) -> None:
|
||||
"""Reset per-bot plugin state between tests."""
|
||||
if bot is None:
|
||||
return
|
||||
ps = _ps(bot)
|
||||
for task in ps["pollers"].values():
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
_pollers.clear()
|
||||
_subscriptions.clear()
|
||||
_errors.clear()
|
||||
ps["pollers"].clear()
|
||||
ps["subs"].clear()
|
||||
ps["errors"].clear()
|
||||
ps["poll_count"].clear()
|
||||
|
||||
|
||||
def _fake_yt(keyword):
|
||||
@@ -595,8 +598,8 @@ class TestCmdAlertAdd:
|
||||
assert len(data["seen"]["yt"]) == 2
|
||||
assert len(data["seen"]["tw"]) == 2
|
||||
assert len(data["seen"]["sx"]) == 2
|
||||
assert "#test:mc-speed" in _pollers
|
||||
_stop_poller("#test:mc-speed")
|
||||
assert "#test:mc-speed" in _ps(bot)["pollers"]
|
||||
_stop_poller(bot, "#test:mc-speed")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -644,7 +647,7 @@ class TestCmdAlertAdd:
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
await cmd_alert(bot, _msg("!alert add dupe other keyword"))
|
||||
assert "already exists" in bot.replied[0]
|
||||
_stop_poller("#test:dupe")
|
||||
_stop_poller(bot, "#test:dupe")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -681,7 +684,7 @@ class TestCmdAlertAdd:
|
||||
assert len(data["seen"]["yt"]) == 2
|
||||
assert len(data["seen"].get("tw", [])) == 0
|
||||
assert len(data["seen"]["sx"]) == 2
|
||||
_stop_poller("#test:partial")
|
||||
_stop_poller(bot, "#test:partial")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -704,7 +707,7 @@ class TestCmdAlertDel:
|
||||
await cmd_alert(bot, _msg("!alert del todel"))
|
||||
assert "Removed 'todel'" in bot.replied[0]
|
||||
assert _load(bot, "#test:todel") is None
|
||||
assert "#test:todel" not in _pollers
|
||||
assert "#test:todel" not in _ps(bot)["pollers"]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -896,7 +899,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:poll"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -933,7 +936,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:dedup"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -953,7 +956,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:partial"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
backends = {"yt": _fake_yt_error, "tw": _fake_tw, "sx": _fake_sx}
|
||||
|
||||
async def inner():
|
||||
@@ -965,7 +968,7 @@ class TestPollOnce:
|
||||
assert len(tw_msgs) == 2
|
||||
assert len(sx_msgs) == 2
|
||||
# Error counter should be incremented for yt backend
|
||||
assert _errors[key]["yt"] == 1
|
||||
assert _ps(bot)["errors"][key]["yt"] == 1
|
||||
updated = _load(bot, key)
|
||||
assert "yt" in updated.get("last_errors", {})
|
||||
|
||||
@@ -981,7 +984,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:quiet"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -1012,7 +1015,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:cap"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", {"yt": fake_many, "tw": _fake_tw}):
|
||||
@@ -1034,13 +1037,13 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:allerr"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
backends = {"yt": _fake_yt_error, "tw": _fake_tw_error, "sx": _fake_sx_error}
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", backends):
|
||||
await _poll_once(bot, key, announce=True)
|
||||
assert all(v == 1 for v in _errors[key].values())
|
||||
assert all(v == 1 for v in _ps(bot)["errors"][key].values())
|
||||
assert len(bot.sent) == 0
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -1058,13 +1061,13 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:clrerr"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_errors[key] = {"yt": 3, "tw": 3, "sx": 3}
|
||||
_ps(bot)["subs"][key] = data
|
||||
_ps(bot)["errors"][key] = {"yt": 3, "tw": 3, "sx": 3}
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
await _poll_once(bot, key, announce=True)
|
||||
assert all(v == 0 for v in _errors[key].values())
|
||||
assert all(v == 0 for v in _ps(bot)["errors"][key].values())
|
||||
updated = _load(bot, key)
|
||||
assert updated.get("last_errors", {}) == {}
|
||||
|
||||
@@ -1088,10 +1091,11 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:restored" in _pollers
|
||||
task = _pollers["#test:restored"]
|
||||
ps = _ps(bot)
|
||||
assert "#test:restored" in ps["pollers"]
|
||||
task = ps["pollers"]["#test:restored"]
|
||||
assert not task.done()
|
||||
_stop_poller("#test:restored")
|
||||
_stop_poller(bot, "#test:restored")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -1107,10 +1111,11 @@ class TestRestore:
|
||||
_save(bot, "#test:active", data)
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
dummy = asyncio.create_task(asyncio.sleep(9999))
|
||||
_pollers["#test:active"] = dummy
|
||||
ps["pollers"]["#test:active"] = dummy
|
||||
_restore(bot)
|
||||
assert _pollers["#test:active"] is dummy
|
||||
assert ps["pollers"]["#test:active"] is dummy
|
||||
dummy.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -1127,14 +1132,15 @@ class TestRestore:
|
||||
_save(bot, "#test:done", data)
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
done_task = asyncio.create_task(asyncio.sleep(0))
|
||||
await done_task
|
||||
_pollers["#test:done"] = done_task
|
||||
ps["pollers"]["#test:done"] = done_task
|
||||
_restore(bot)
|
||||
new_task = _pollers["#test:done"]
|
||||
new_task = ps["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())
|
||||
@@ -1146,7 +1152,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())
|
||||
|
||||
@@ -1163,8 +1169,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())
|
||||
@@ -1185,16 +1191,17 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:mgmt"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
_start_poller(bot, key)
|
||||
assert key in _pollers
|
||||
assert not _pollers[key].done()
|
||||
_stop_poller(key)
|
||||
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 _subscriptions
|
||||
assert key not in ps["pollers"]
|
||||
assert key not in ps["subs"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1208,21 +1215,22 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:idem"
|
||||
_save(bot, key, data)
|
||||
_subscriptions[key] = data
|
||||
_ps(bot)["subs"][key] = data
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
_start_poller(bot, key)
|
||||
first = _pollers[key]
|
||||
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()
|
||||
_stop_poller("#test:nonexistent")
|
||||
bot = _FakeBot()
|
||||
_stop_poller(bot, "#test:nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1299,7 +1307,7 @@ class TestExtraInHistory:
|
||||
}
|
||||
_save(bot, "#test:hist", data)
|
||||
# Insert a result with extra metadata
|
||||
_save_result("#test", "hist", "hn", {
|
||||
_save_result(bot, "#test", "hist", "hn", {
|
||||
"id": "h1", "title": "Cool HN Post", "url": "https://hn.example.com/1",
|
||||
"date": "2026-01-15", "extra": "42pt 10c",
|
||||
})
|
||||
@@ -1321,7 +1329,7 @@ class TestExtraInHistory:
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:hist2", data)
|
||||
_save_result("#test", "hist2", "yt", {
|
||||
_save_result(bot, "#test", "hist2", "yt", {
|
||||
"id": "y1", "title": "Plain Video", "url": "https://yt.example.com/1",
|
||||
"date": "", "extra": "",
|
||||
})
|
||||
@@ -1348,7 +1356,7 @@ class TestExtraInInfo:
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:inf", data)
|
||||
short_id = _save_result("#test", "inf", "gh", {
|
||||
short_id = _save_result(bot, "#test", "inf", "gh", {
|
||||
"id": "g1", "title": "cool/repo: A cool project",
|
||||
"url": "https://github.com/cool/repo",
|
||||
"date": "2026-01-10", "extra": "42* 5fk",
|
||||
@@ -1370,7 +1378,7 @@ class TestExtraInInfo:
|
||||
"interval": 300, "seen": {}, "last_poll": "", "last_error": "",
|
||||
}
|
||||
_save(bot, "#test:inf2", data)
|
||||
short_id = _save_result("#test", "inf2", "yt", {
|
||||
short_id = _save_result(bot, "#test", "inf2", "yt", {
|
||||
"id": "y2", "title": "Some Video",
|
||||
"url": "https://youtube.com/watch?v=y2",
|
||||
"date": "", "extra": "",
|
||||
|
||||
Reference in New Issue
Block a user