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:
@@ -20,16 +20,15 @@ from plugins.cron import ( # noqa: E402
|
||||
_MAX_JOBS,
|
||||
_delete,
|
||||
_format_duration,
|
||||
_jobs,
|
||||
_load,
|
||||
_make_id,
|
||||
_parse_duration,
|
||||
_ps,
|
||||
_restore,
|
||||
_save,
|
||||
_start_job,
|
||||
_state_key,
|
||||
_stop_job,
|
||||
_tasks,
|
||||
cmd_cron,
|
||||
on_connect,
|
||||
)
|
||||
@@ -67,6 +66,7 @@ class _FakeBot:
|
||||
self.replied: list[str] = []
|
||||
self.dispatched: list[Message] = []
|
||||
self.state = _FakeState()
|
||||
self._pstate: dict = {}
|
||||
self._admin = admin
|
||||
self.prefix = "!"
|
||||
|
||||
@@ -99,13 +99,16 @@ def _pm(text: str, nick: str = "admin") -> Message:
|
||||
)
|
||||
|
||||
|
||||
def _clear() -> None:
|
||||
"""Reset module-level state between tests."""
|
||||
for task in _tasks.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["tasks"].values():
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
_tasks.clear()
|
||||
_jobs.clear()
|
||||
ps["tasks"].clear()
|
||||
ps["jobs"].clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -226,7 +229,6 @@ class TestStateHelpers:
|
||||
|
||||
class TestCmdCronAdd:
|
||||
def test_add_success(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
async def inner():
|
||||
@@ -245,50 +247,43 @@ class TestCmdCronAdd:
|
||||
assert data["interval"] == 300
|
||||
assert data["channel"] == "#ops"
|
||||
# Verify task started
|
||||
assert len(_tasks) == 1
|
||||
_clear()
|
||||
assert len(_ps(bot)["tasks"]) == 1
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_add_requires_channel(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping")))
|
||||
assert "Use this command in a channel" in bot.replied[0]
|
||||
|
||||
def test_add_missing_args(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add 5m")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_add_invalid_interval(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping")))
|
||||
assert "Invalid interval" in bot.replied[0]
|
||||
|
||||
def test_add_interval_too_short(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping")))
|
||||
assert "Minimum interval" in bot.replied[0]
|
||||
|
||||
def test_add_interval_too_long(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping")))
|
||||
assert "Maximum interval" in bot.replied[0]
|
||||
|
||||
def test_add_bad_target(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping")))
|
||||
assert "Target must be a channel" in bot.replied[0]
|
||||
|
||||
def test_add_job_limit(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
for i in range(_MAX_JOBS):
|
||||
_save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"})
|
||||
@@ -297,7 +292,6 @@ class TestCmdCronAdd:
|
||||
assert "limit reached" in bot.replied[0]
|
||||
|
||||
def test_add_admin_required(self):
|
||||
_clear()
|
||||
bot = _FakeBot(admin=False)
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping")))
|
||||
# The @command(admin=True) decorator handles this via bot._dispatch_command,
|
||||
@@ -313,7 +307,6 @@ class TestCmdCronAdd:
|
||||
|
||||
class TestCmdCronDel:
|
||||
def test_del_success(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
async def inner():
|
||||
@@ -327,25 +320,22 @@ class TestCmdCronDel:
|
||||
assert "Removed" in bot.replied[0]
|
||||
assert cron_id in bot.replied[0]
|
||||
assert len(bot.state.keys("cron")) == 0
|
||||
_clear()
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_del_nonexistent(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron del nosuch")))
|
||||
assert "No cron job" in bot.replied[0]
|
||||
|
||||
def test_del_missing_id(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron del")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_del_with_hash_prefix(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
async def inner():
|
||||
@@ -356,7 +346,7 @@ class TestCmdCronDel:
|
||||
bot.replied.clear()
|
||||
await cmd_cron(bot, _msg(f"!cron del #{cron_id}"))
|
||||
assert "Removed" in bot.replied[0]
|
||||
_clear()
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -368,13 +358,11 @@ class TestCmdCronDel:
|
||||
|
||||
class TestCmdCronList:
|
||||
def test_list_empty(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron list")))
|
||||
assert "No cron jobs" in bot.replied[0]
|
||||
|
||||
def test_list_populated(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
_save(bot, "#test:abc123", {
|
||||
"id": "abc123", "channel": "#test",
|
||||
@@ -386,13 +374,11 @@ class TestCmdCronList:
|
||||
assert "!rss check news" in bot.replied[0]
|
||||
|
||||
def test_list_requires_channel(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _pm("!cron 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", {
|
||||
"id": "mine", "channel": "#test",
|
||||
@@ -413,13 +399,11 @@ class TestCmdCronList:
|
||||
|
||||
class TestCmdCronUsage:
|
||||
def test_no_args(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
|
||||
def test_unknown_subcommand(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
asyncio.run(cmd_cron(bot, _msg("!cron foobar")))
|
||||
assert "Usage:" in bot.replied[0]
|
||||
@@ -431,7 +415,6 @@ class TestCmdCronUsage:
|
||||
|
||||
class TestRestore:
|
||||
def test_restore_spawns_tasks(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "abc123", "channel": "#test",
|
||||
@@ -443,15 +426,15 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:abc123" in _tasks
|
||||
assert not _tasks["#test:abc123"].done()
|
||||
_clear()
|
||||
ps = _ps(bot)
|
||||
assert "#test:abc123" in ps["tasks"]
|
||||
assert not ps["tasks"]["#test:abc123"].done()
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_restore_skips_active(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "active", "channel": "#test",
|
||||
@@ -462,17 +445,17 @@ class TestRestore:
|
||||
_save(bot, "#test:active", data)
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
dummy = asyncio.create_task(asyncio.sleep(9999))
|
||||
_tasks["#test:active"] = dummy
|
||||
ps["tasks"]["#test:active"] = dummy
|
||||
_restore(bot)
|
||||
assert _tasks["#test:active"] is dummy
|
||||
assert ps["tasks"]["#test:active"] is dummy
|
||||
dummy.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_restore_replaces_done_task(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "done", "channel": "#test",
|
||||
@@ -483,31 +466,30 @@ class TestRestore:
|
||||
_save(bot, "#test:done", data)
|
||||
|
||||
async def inner():
|
||||
ps = _ps(bot)
|
||||
done_task = asyncio.create_task(asyncio.sleep(0))
|
||||
await done_task
|
||||
_tasks["#test:done"] = done_task
|
||||
ps["tasks"]["#test:done"] = done_task
|
||||
_restore(bot)
|
||||
new_task = _tasks["#test:done"]
|
||||
new_task = ps["tasks"]["#test:done"]
|
||||
assert new_task is not done_task
|
||||
assert not new_task.done()
|
||||
_clear()
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_restore_skips_bad_json(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
bot.state.set("cron", "#test:bad", "not json{{{")
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:bad" not in _tasks
|
||||
assert "#test:bad" not in _ps(bot)["tasks"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_on_connect_calls_restore(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "conn", "channel": "#test",
|
||||
@@ -520,8 +502,8 @@ class TestRestore:
|
||||
async def inner():
|
||||
msg = _msg("", target="botname")
|
||||
await on_connect(bot, msg)
|
||||
assert "#test:conn" in _tasks
|
||||
_clear()
|
||||
assert "#test:conn" in _ps(bot)["tasks"]
|
||||
_clear(bot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -534,7 +516,6 @@ class TestRestore:
|
||||
class TestCronLoop:
|
||||
def test_dispatches_command(self):
|
||||
"""Cron loop dispatches a synthetic message after interval."""
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
|
||||
async def inner():
|
||||
@@ -545,10 +526,10 @@ class TestCronLoop:
|
||||
"added_by": "admin",
|
||||
}
|
||||
key = "#test:loop1"
|
||||
_jobs[key] = data
|
||||
_ps(bot)["jobs"][key] = data
|
||||
_start_job(bot, key)
|
||||
await asyncio.sleep(0.15)
|
||||
_stop_job(key)
|
||||
_stop_job(bot, key)
|
||||
await asyncio.sleep(0)
|
||||
# Should have dispatched at least once
|
||||
assert len(bot.dispatched) >= 1
|
||||
@@ -560,8 +541,7 @@ class TestCronLoop:
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_loop_stops_on_job_removal(self):
|
||||
"""Cron loop exits when job is removed from _jobs."""
|
||||
_clear()
|
||||
"""Cron loop exits when job is removed from jobs dict."""
|
||||
bot = _FakeBot()
|
||||
|
||||
async def inner():
|
||||
@@ -572,12 +552,13 @@ class TestCronLoop:
|
||||
"added_by": "admin",
|
||||
}
|
||||
key = "#test:loop2"
|
||||
_jobs[key] = data
|
||||
ps = _ps(bot)
|
||||
ps["jobs"][key] = data
|
||||
_start_job(bot, key)
|
||||
await asyncio.sleep(0.02)
|
||||
_jobs.pop(key, None)
|
||||
ps["jobs"].pop(key, None)
|
||||
await asyncio.sleep(0.1)
|
||||
task = _tasks.get(key)
|
||||
task = ps["tasks"].get(key)
|
||||
if task:
|
||||
assert task.done()
|
||||
|
||||
@@ -590,7 +571,6 @@ class TestCronLoop:
|
||||
|
||||
class TestJobManagement:
|
||||
def test_start_and_stop(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "mgmt", "channel": "#test",
|
||||
@@ -599,21 +579,21 @@ class TestJobManagement:
|
||||
"added_by": "admin",
|
||||
}
|
||||
key = "#test:mgmt"
|
||||
_jobs[key] = data
|
||||
ps = _ps(bot)
|
||||
ps["jobs"][key] = data
|
||||
|
||||
async def inner():
|
||||
_start_job(bot, key)
|
||||
assert key in _tasks
|
||||
assert not _tasks[key].done()
|
||||
_stop_job(key)
|
||||
assert key in ps["tasks"]
|
||||
assert not ps["tasks"][key].done()
|
||||
_stop_job(bot, key)
|
||||
await asyncio.sleep(0)
|
||||
assert key not in _tasks
|
||||
assert key not in _jobs
|
||||
assert key not in ps["tasks"]
|
||||
assert key not in ps["jobs"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_start_idempotent(self):
|
||||
_clear()
|
||||
bot = _FakeBot()
|
||||
data = {
|
||||
"id": "idem", "channel": "#test",
|
||||
@@ -622,18 +602,19 @@ class TestJobManagement:
|
||||
"added_by": "admin",
|
||||
}
|
||||
key = "#test:idem"
|
||||
_jobs[key] = data
|
||||
ps = _ps(bot)
|
||||
ps["jobs"][key] = data
|
||||
|
||||
async def inner():
|
||||
_start_job(bot, key)
|
||||
first = _tasks[key]
|
||||
first = ps["tasks"][key]
|
||||
_start_job(bot, key)
|
||||
assert _tasks[key] is first
|
||||
_stop_job(key)
|
||||
assert ps["tasks"][key] is first
|
||||
_stop_job(bot, key)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
def test_stop_nonexistent(self):
|
||||
_clear()
|
||||
_stop_job("#test:nonexistent")
|
||||
bot = _FakeBot()
|
||||
_stop_job(bot, "#test:nonexistent")
|
||||
|
||||
Reference in New Issue
Block a user