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:
@@ -19,16 +19,14 @@ _spec.loader.exec_module(_mod)
|
||||
from plugins.twitch import ( # noqa: E402
|
||||
_compact_num,
|
||||
_delete,
|
||||
_errors,
|
||||
_load,
|
||||
_poll_once,
|
||||
_pollers,
|
||||
_ps,
|
||||
_restore,
|
||||
_save,
|
||||
_start_poller,
|
||||
_state_key,
|
||||
_stop_poller,
|
||||
_streamers,
|
||||
_truncate,
|
||||
_validate_name,
|
||||
cmd_twitch,
|
||||
@@ -131,6 +129,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:
|
||||
@@ -160,13 +159,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()
|
||||
_streamers.clear()
|
||||
_errors.clear()
|
||||
"""No-op -- state is per-bot now, each _FakeBot starts fresh."""
|
||||
|
||||
|
||||
def _fake_query_live(login):
|
||||
@@ -439,8 +432,8 @@ class TestCmdTwitchFollow:
|
||||
assert data["name"] == "xqc"
|
||||
assert data["channel"] == "#test"
|
||||
assert data["was_live"] is False
|
||||
assert "#test:xqc" in _pollers
|
||||
_stop_poller("#test:xqc")
|
||||
assert "#test:xqc" in _ps(bot)["pollers"]
|
||||
_stop_poller(bot, "#test:xqc")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -457,7 +450,7 @@ class TestCmdTwitchFollow:
|
||||
data = _load(bot, "#test:my-streamer")
|
||||
assert data is not None
|
||||
assert data["name"] == "my-streamer"
|
||||
_stop_poller("#test:my-streamer")
|
||||
_stop_poller(bot, "#test:my-streamer")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -476,7 +469,7 @@ class TestCmdTwitchFollow:
|
||||
assert data["stream_id"] == "12345"
|
||||
# Should NOT have announced (seed, not transition)
|
||||
assert len(bot.sent) == 0
|
||||
_stop_poller("#test:xqc")
|
||||
_stop_poller(bot, "#test:xqc")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -585,7 +578,7 @@ class TestCmdTwitchUnfollow:
|
||||
await cmd_twitch(bot, _msg("!twitch unfollow xqc"))
|
||||
assert "Unfollowed 'xqc'" in bot.replied[0]
|
||||
assert _load(bot, "#test:xqc") is None
|
||||
assert "#test:xqc" not in _pollers
|
||||
assert "#test:xqc" not in _ps(bot)["pollers"]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -798,7 +791,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_live):
|
||||
@@ -828,7 +821,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_live):
|
||||
@@ -851,7 +844,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_live_new_stream):
|
||||
@@ -877,7 +870,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_offline):
|
||||
@@ -899,13 +892,13 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_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"
|
||||
|
||||
@@ -923,7 +916,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_live):
|
||||
@@ -947,7 +940,7 @@ class TestPollOnce:
|
||||
}
|
||||
key = "#test:streamer"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][key] = data
|
||||
|
||||
async def inner():
|
||||
with patch.object(_mod, "_query_stream", _fake_query_live_no_game):
|
||||
@@ -990,10 +983,10 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
_restore(bot)
|
||||
assert "#test:xqc" in _pollers
|
||||
task = _pollers["#test:xqc"]
|
||||
assert "#test:xqc" in _ps(bot)["pollers"]
|
||||
task = _ps(bot)["pollers"]["#test:xqc"]
|
||||
assert not task.done()
|
||||
_stop_poller("#test:xqc")
|
||||
_stop_poller(bot, "#test:xqc")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -1011,9 +1004,9 @@ class TestRestore:
|
||||
|
||||
async def inner():
|
||||
dummy = asyncio.create_task(asyncio.sleep(9999))
|
||||
_pollers["#test:xqc"] = dummy
|
||||
_ps(bot)["pollers"]["#test:xqc"] = dummy
|
||||
_restore(bot)
|
||||
assert _pollers["#test:xqc"] is dummy
|
||||
assert _ps(bot)["pollers"]["#test:xqc"] is dummy
|
||||
dummy.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -1033,12 +1026,12 @@ class TestRestore:
|
||||
async def inner():
|
||||
done_task = asyncio.create_task(asyncio.sleep(0))
|
||||
await done_task
|
||||
_pollers["#test:xqc"] = done_task
|
||||
_ps(bot)["pollers"]["#test:xqc"] = done_task
|
||||
_restore(bot)
|
||||
new_task = _pollers["#test:xqc"]
|
||||
new_task = _ps(bot)["pollers"]["#test:xqc"]
|
||||
assert new_task is not done_task
|
||||
assert not new_task.done()
|
||||
_stop_poller("#test:xqc")
|
||||
_stop_poller(bot, "#test:xqc")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -1050,7 +1043,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())
|
||||
|
||||
@@ -1068,8 +1061,8 @@ class TestRestore:
|
||||
async def inner():
|
||||
msg = _msg("", target="botname")
|
||||
await on_connect(bot, msg)
|
||||
assert "#test:xqc" in _pollers
|
||||
_stop_poller("#test:xqc")
|
||||
assert "#test:xqc" in _ps(bot)["pollers"]
|
||||
_stop_poller(bot, "#test:xqc")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(inner())
|
||||
@@ -1091,16 +1084,17 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][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 _streamers
|
||||
assert key not in ps["pollers"]
|
||||
assert key not in ps["streamers"]
|
||||
|
||||
asyncio.run(inner())
|
||||
|
||||
@@ -1115,21 +1109,23 @@ class TestPollerManagement:
|
||||
}
|
||||
key = "#test:xqc"
|
||||
_save(bot, key, data)
|
||||
_streamers[key] = data
|
||||
_ps(bot)["streamers"][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()
|
||||
_stop_poller("#test:nonexistent")
|
||||
bot = _FakeBot()
|
||||
_stop_poller(bot, "#test:nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user