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

@@ -40,11 +40,15 @@ _BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
_MAX_TITLE_LEN = 80
_MAX_CHANNELS = 20
# -- Module-level tracking ---------------------------------------------------
# -- Per-bot runtime state ---------------------------------------------------
_pollers: dict[str, asyncio.Task] = {}
_channels: dict[str, dict] = {}
_errors: dict[str, int] = {}
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("yt", {
"pollers": {},
"channels": {},
"errors": {},
})
# -- Pure helpers ------------------------------------------------------------
@@ -317,12 +321,13 @@ def _delete(bot, key: str) -> None:
async def _poll_once(bot, key: str, announce: bool = True) -> None:
"""Single poll cycle for one YouTube channel."""
data = _channels.get(key)
ps = _ps(bot)
data = ps["channels"].get(key)
if data is None:
data = _load(bot, key)
if data is None:
return
_channels[key] = data
ps["channels"][key] = data
url = data["feed_url"]
etag = data.get("etag", "")
@@ -338,16 +343,16 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
if result["error"]:
data["last_error"] = result["error"]
_errors[key] = _errors.get(key, 0) + 1
_channels[key] = data
ps["errors"][key] = ps["errors"].get(key, 0) + 1
ps["channels"][key] = data
_save(bot, key, data)
return
# HTTP 304 -- not modified
if result["status"] == 304:
data["last_error"] = ""
_errors[key] = 0
_channels[key] = data
ps["errors"][key] = 0
ps["channels"][key] = data
_save(bot, key, data)
return
@@ -355,14 +360,14 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
data["etag"] = result["etag"]
data["last_modified"] = result["last_modified"]
data["last_error"] = ""
_errors[key] = 0
ps["errors"][key] = 0
try:
_, items = _parse_feed(result["body"])
except Exception as exc:
data["last_error"] = f"Parse error: {exc}"
_errors[key] = _errors.get(key, 0) + 1
_channels[key] = data
ps["errors"][key] = ps["errors"].get(key, 0) + 1
ps["channels"][key] = data
_save(bot, key, data)
return
@@ -429,7 +434,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
seen_list = seen_list[-_MAX_SEEN:]
data["seen"] = seen_list
_channels[key] = data
ps["channels"][key] = data
_save(bot, key, data)
@@ -437,12 +442,13 @@ async def _poll_loop(bot, key: str) -> None:
"""Infinite poll loop for one YouTube channel."""
try:
while True:
data = _channels.get(key) or _load(bot, key)
ps = _ps(bot)
data = ps["channels"].get(key) or _load(bot, key)
if data is None:
return
interval = data.get("interval", _DEFAULT_INTERVAL)
# Back off on consecutive errors
errs = _errors.get(key, 0)
errs = ps["errors"].get(key, 0)
if errs >= 5:
interval = min(interval * 2, _MAX_INTERVAL)
await asyncio.sleep(interval)
@@ -453,34 +459,37 @@ async def _poll_loop(bot, key: str) -> None:
def _start_poller(bot, key: str) -> None:
"""Create and track a poller task."""
existing = _pollers.get(key)
ps = _ps(bot)
existing = ps["pollers"].get(key)
if existing and not existing.done():
return
task = asyncio.create_task(_poll_loop(bot, key))
_pollers[key] = task
ps["pollers"][key] = task
def _stop_poller(key: str) -> None:
def _stop_poller(bot, key: str) -> None:
"""Cancel and remove a poller task."""
task = _pollers.pop(key, None)
ps = _ps(bot)
task = ps["pollers"].pop(key, None)
if task and not task.done():
task.cancel()
_channels.pop(key, None)
_errors.pop(key, 0)
ps["channels"].pop(key, None)
ps["errors"].pop(key, 0)
# -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Rebuild pollers from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("yt"):
existing = _pollers.get(key)
existing = ps["pollers"].get(key)
if existing and not existing.done():
continue
data = _load(bot, key)
if data is None:
continue
_channels[key] = data
ps["channels"][key] = data
_start_poller(bot, key)
@@ -548,9 +557,10 @@ async def cmd_yt(bot, message):
if data is None:
await bot.reply(message, f"No channel '{name}' in this channel")
return
_channels[key] = data
ps = _ps(bot)
ps["channels"][key] = data
await _poll_once(bot, key, announce=True)
data = _channels.get(key, data)
data = ps["channels"].get(key, data)
if data.get("last_error"):
await bot.reply(message, f"{name}: error -- {data['last_error']}")
else:
@@ -652,7 +662,7 @@ async def cmd_yt(bot, message):
"title": channel_title,
}
_save(bot, key, data)
_channels[key] = data
_ps(bot)["channels"][key] = data
_start_poller(bot, key)
display = channel_title or name
@@ -683,7 +693,7 @@ async def cmd_yt(bot, message):
await bot.reply(message, f"No channel '{name}' in this channel")
return
_stop_poller(key)
_stop_poller(bot, key)
_delete(bot, key)
await bot.reply(message, f"Unfollowed '{name}'")
return