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:
@@ -22,13 +22,11 @@ from plugins.pastemoni import ( # noqa: E402
|
||||
_MAX_SEEN,
|
||||
_ArchiveParser,
|
||||
_delete,
|
||||
_errors,
|
||||
_fetch_gists,
|
||||
_fetch_pastebin,
|
||||
_load,
|
||||
_monitors,
|
||||
_poll_once,
|
||||
_pollers,
|
||||
_ps,
|
||||
_restore,
|
||||
_save,
|
||||
_snippet_around,
|
||||
@@ -132,6 +130,7 @@ class _FakeBot:
|
||||
self.replied: list[str] = []
|
||||
self.state = _FakeState()
|
||||
self.registry = _FakeRegistry()
|
||||
self._pstate: dict = {}
|
||||
self._admin = admin
|
||||
|
||||
async def send(self, target: str, text: str) -> None:
|
||||
@@ -163,14 +162,17 @@ 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()
|
||||
_monitors.clear()
|
||||
_errors.clear()
|
||||
ps["pollers"].clear()
|
||||
ps["monitors"].clear()
|
||||
ps["errors"].clear()
|
||||
|
||||
|
||||
def _fake_pb(keyword):
|
||||
@@ -428,7 +430,6 @@ class TestStateHelpers:
|
||||
|
||||
class TestPollOnce:
|
||||
def test_new_items_announced(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "poll", "channel": "#test",
|
||||
@@ -437,7 +438,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:poll"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -451,7 +452,6 @@ class TestPollOnce:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_seen_items_deduped(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "dedup", "channel": "#test",
|
||||
@@ -461,7 +461,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:dedup"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -472,7 +472,6 @@ class TestPollOnce:
|
||||
|
||||
def test_error_increments_counter(self):
|
||||
"""All backends failing increments the error counter."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "errs", "channel": "#test",
|
||||
@@ -481,20 +480,19 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:errs"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
all_fail = {"pb": _fake_pb_error, "gh": _fake_gh_error}
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", all_fail):
|
||||
await _poll_once(bot, key, announce=True)
|
||||
assert _errors[key] == 1
|
||||
assert _ps(bot)["errors"][key] == 1
|
||||
assert len(bot.sent) == 0
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_partial_failure_resets_counter(self):
|
||||
"""One backend succeeding resets the error counter."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "partial", "channel": "#test",
|
||||
@@ -503,14 +501,14 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:partial"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_errors[key] = 3
|
||||
_ps(bot)["monitors"][key] = data
|
||||
_ps(bot)["errors"][key] = 3
|
||||
partial_fail = {"pb": _fake_pb_error, "gh": _fake_gh}
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", partial_fail):
|
||||
await _poll_once(bot, key, announce=True)
|
||||
assert _errors[key] == 0
|
||||
assert _ps(bot)["errors"][key] == 0
|
||||
gh_msgs = [s for t, s in bot.sent if t == "#test" and "[gh]" in s]
|
||||
assert len(gh_msgs) == 1
|
||||
|
||||
@@ -518,7 +516,6 @@ class TestPollOnce:
|
||||
|
||||
def test_max_announce_cap(self):
|
||||
"""Only MAX_ANNOUNCE items announced per backend."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
def _fake_many(keyword):
|
||||
@@ -535,7 +532,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:cap"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}):
|
||||
@@ -548,7 +545,6 @@ class TestPollOnce:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_no_announce_flag(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "quiet", "channel": "#test",
|
||||
@@ -557,7 +553,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:quiet"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
@@ -571,7 +567,6 @@ class TestPollOnce:
|
||||
|
||||
def test_seen_cap(self):
|
||||
"""Seen list capped at MAX_SEEN per backend."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
def _fake_many(keyword):
|
||||
@@ -587,7 +582,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:seencap"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}):
|
||||
@@ -605,7 +600,6 @@ class TestPollOnce:
|
||||
|
||||
class TestCmdAdd:
|
||||
def test_add_success(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
|
||||
async def inner():
|
||||
@@ -621,38 +615,33 @@ class TestCmdAdd:
|
||||
assert data["channel"] == "#test"
|
||||
assert len(data["seen"]["pb"]) == 2
|
||||
assert len(data["seen"]["gh"]) == 1
|
||||
assert "#test:leak-watch" in _pollers
|
||||
_stop_poller("#test:leak-watch")
|
||||
assert "#test:leak-watch" in _ps(bot)["pollers"]
|
||||
_stop_poller(bot, "#test:leak-watch")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_add_requires_admin(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=False)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add test keyword")))
|
||||
assert "Permission denied" in bot.replied[0]
|
||||
|
||||
def test_add_requires_channel(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni add test keyword")))
|
||||
assert "Use this command in a channel" in bot.replied[0]
|
||||
|
||||
def test_add_invalid_name(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add BAD! keyword")))
|
||||
assert "Invalid name" in bot.replied[0]
|
||||
|
||||
def test_add_missing_keyword(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add myname")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_add_duplicate(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
|
||||
async def inner():
|
||||
@@ -663,13 +652,12 @@ class TestCmdAdd:
|
||||
with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS):
|
||||
await cmd_pastemoni(bot, _msg("!pastemoni add dupe other"))
|
||||
assert "already exists" in bot.replied[0]
|
||||
_stop_poller("#test:dupe")
|
||||
_stop_poller(bot, "#test:dupe")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_add_limit(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
for i in range(20):
|
||||
_save(bot, f"#test:mon{i}", {"name": f"mon{i}", "channel": "#test"})
|
||||
@@ -684,7 +672,6 @@ class TestCmdAdd:
|
||||
|
||||
class TestCmdDel:
|
||||
def test_del_success(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
|
||||
async def inner():
|
||||
@@ -695,25 +682,22 @@ class TestCmdDel:
|
||||
await cmd_pastemoni(bot, _msg("!pastemoni 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())
|
||||
|
||||
def test_del_requires_admin(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=False)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del test")))
|
||||
assert "Permission denied" in bot.replied[0]
|
||||
|
||||
def test_del_nonexistent(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del nosuch")))
|
||||
assert "No monitor" in bot.replied[0]
|
||||
|
||||
def test_del_no_name(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=True)
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
@@ -721,13 +705,11 @@ class TestCmdDel:
|
||||
|
||||
class TestCmdList:
|
||||
def test_list_empty(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni list")))
|
||||
assert "No monitors" in bot.replied[0]
|
||||
|
||||
def test_list_populated(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:leaks", {
|
||||
"name": "leaks", "channel": "#test", "keyword": "api_key",
|
||||
@@ -743,7 +725,6 @@ class TestCmdList:
|
||||
assert "creds" in bot.replied[0]
|
||||
|
||||
def test_list_shows_errors(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:broken", {
|
||||
"name": "broken", "channel": "#test", "keyword": "test",
|
||||
@@ -754,13 +735,11 @@ class TestCmdList:
|
||||
assert "1 errors" in bot.replied[0]
|
||||
|
||||
def test_list_requires_channel(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni list")))
|
||||
assert "Use this command in a channel" in bot.replied[0]
|
||||
|
||||
def test_list_channel_isolation(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:mine", {
|
||||
"name": "mine", "channel": "#test", "keyword": "test",
|
||||
@@ -777,7 +756,6 @@ class TestCmdList:
|
||||
|
||||
class TestCmdCheck:
|
||||
def test_check_success(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "chk", "channel": "#test",
|
||||
@@ -794,19 +772,16 @@ class TestCmdCheck:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_check_nonexistent(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check nope")))
|
||||
assert "No monitor" in bot.replied[0]
|
||||
|
||||
def test_check_requires_channel(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni check test")))
|
||||
assert "Use this command in a channel" in bot.replied[0]
|
||||
|
||||
def test_check_shows_error(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "errchk", "channel": "#test",
|
||||
@@ -824,7 +799,6 @@ class TestCmdCheck:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_check_announces_new_items(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "news", "channel": "#test",
|
||||
@@ -845,7 +819,6 @@ class TestCmdCheck:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_check_no_name(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
@@ -853,13 +826,11 @@ class TestCmdCheck:
|
||||
|
||||
class TestCmdUsage:
|
||||
def test_no_args(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_unknown_subcommand(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni foobar")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
@@ -871,7 +842,6 @@ class TestCmdUsage:
|
||||
|
||||
class TestRestore:
|
||||
def test_pollers_rebuilt_from_state(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "restored", "channel": "#test",
|
||||
@@ -882,15 +852,14 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:restored" in _pollers
|
||||
assert not _pollers["#test:restored"].done()
|
||||
_stop_poller("#test:restored")
|
||||
assert "#test:restored" in _ps(bot)["pollers"]
|
||||
assert not _ps(bot)["pollers"]["#test:restored"].done()
|
||||
_stop_poller(bot, "#test:restored")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_restore_skips_active(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "active", "channel": "#test",
|
||||
@@ -901,16 +870,15 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
dummy = asyncio.create_task(asyncio.sleep(9999))
|
||||
_pollers["#test:active"] = dummy
|
||||
_ps(bot)["pollers"]["#test:active"] = dummy
|
||||
_restore(bot)
|
||||
assert _pollers["#test:active"] is dummy
|
||||
assert _ps(bot)["pollers"]["#test:active"] is dummy
|
||||
dummy.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_restore_replaces_done_task(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "done", "channel": "#test",
|
||||
@@ -922,29 +890,27 @@ class TestRestore:
|
||||
async def inner():
|
||||
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)
|
||||
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())
|
||||
|
||||
def test_restore_skips_bad_json(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
bot.state.set("pastemoni", "#test:bad", "not json{{{")
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:bad" not in _pollers
|
||||
assert "#test:bad" not in _ps(bot)["pollers"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_on_connect_calls_restore(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "conn", "channel": "#test",
|
||||
@@ -956,8 +922,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())
|
||||
@@ -969,7 +935,6 @@ class TestRestore:
|
||||
|
||||
class TestPollerManagement:
|
||||
def test_start_and_stop(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "mgmt", "channel": "#test",
|
||||
@@ -978,21 +943,20 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:mgmt"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
_start_poller(bot, key)
|
||||
assert key in _pollers
|
||||
assert not _pollers[key].done()
|
||||
_stop_poller(key)
|
||||
assert key in _ps(bot)["pollers"]
|
||||
assert not _ps(bot)["pollers"][key].done()
|
||||
_stop_poller(bot, key)
|
||||
await asyncio.sleep(0)
|
||||
assert key not in _pollers
|
||||
assert key not in _monitors
|
||||
assert key not in _ps(bot)["pollers"]
|
||||
assert key not in _ps(bot)["monitors"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_start_idempotent(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"keyword": "test", "name": "idem", "channel": "#test",
|
||||
@@ -1001,18 +965,18 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:idem"
|
||||
_save(bot, key, data)
|
||||
_monitors[key] = data
|
||||
_ps(bot)["monitors"][key] = data
|
||||
|
||||
async def inner():
|
||||
_start_poller(bot, key)
|
||||
first = _pollers[key]
|
||||
first = _ps(bot)["pollers"][key]
|
||||
_start_poller(bot, key)
|
||||
assert _pollers[key] is first
|
||||
_stop_poller(key)
|
||||
assert _ps(bot)["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")
|
||||
|
||||
Reference in New Issue
Block a user