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:
@@ -27,11 +27,15 @@ _MAX_FEEDS = 20
|
||||
_ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||
_DC_NS = "{http://purl.org/dc/elements/1.1/}"
|
||||
|
||||
# -- Module-level tracking ---------------------------------------------------
|
||||
# -- Per-bot runtime state ---------------------------------------------------
|
||||
|
||||
_pollers: dict[str, asyncio.Task] = {}
|
||||
_feeds: dict[str, dict] = {}
|
||||
_errors: dict[str, int] = {}
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
return bot._pstate.setdefault("rss", {
|
||||
"pollers": {},
|
||||
"feeds": {},
|
||||
"errors": {},
|
||||
})
|
||||
|
||||
|
||||
# -- Pure helpers ------------------------------------------------------------
|
||||
@@ -209,12 +213,13 @@ def _parse_feed(body: bytes) -> tuple[str, list[dict]]:
|
||||
|
||||
async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
||||
"""Single poll cycle for one feed."""
|
||||
data = _feeds.get(key)
|
||||
ps = _ps(bot)
|
||||
data = ps["feeds"].get(key)
|
||||
if data is None:
|
||||
data = _load(bot, key)
|
||||
if data is None:
|
||||
return
|
||||
_feeds[key] = data
|
||||
ps["feeds"][key] = data
|
||||
|
||||
url = data["url"]
|
||||
etag = data.get("etag", "")
|
||||
@@ -230,16 +235,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
|
||||
_feeds[key] = data
|
||||
ps["errors"][key] = ps["errors"].get(key, 0) + 1
|
||||
ps["feeds"][key] = data
|
||||
_save(bot, key, data)
|
||||
return
|
||||
|
||||
# HTTP 304 -- not modified
|
||||
if result["status"] == 304:
|
||||
data["last_error"] = ""
|
||||
_errors[key] = 0
|
||||
_feeds[key] = data
|
||||
ps["errors"][key] = 0
|
||||
ps["feeds"][key] = data
|
||||
_save(bot, key, data)
|
||||
return
|
||||
|
||||
@@ -247,14 +252,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:
|
||||
feed_title, items = _parse_feed(result["body"])
|
||||
except Exception as exc:
|
||||
data["last_error"] = f"Parse error: {exc}"
|
||||
_errors[key] = _errors.get(key, 0) + 1
|
||||
_feeds[key] = data
|
||||
ps["errors"][key] = ps["errors"].get(key, 0) + 1
|
||||
ps["feeds"][key] = data
|
||||
_save(bot, key, data)
|
||||
return
|
||||
|
||||
@@ -292,7 +297,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
||||
seen_list = seen_list[-_MAX_SEEN:]
|
||||
data["seen"] = seen_list
|
||||
|
||||
_feeds[key] = data
|
||||
ps["feeds"][key] = data
|
||||
_save(bot, key, data)
|
||||
|
||||
|
||||
@@ -300,12 +305,13 @@ async def _poll_loop(bot, key: str) -> None:
|
||||
"""Infinite poll loop for one feed."""
|
||||
try:
|
||||
while True:
|
||||
data = _feeds.get(key) or _load(bot, key)
|
||||
ps = _ps(bot)
|
||||
data = ps["feeds"].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)
|
||||
@@ -316,34 +322,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()
|
||||
_feeds.pop(key, None)
|
||||
_errors.pop(key, 0)
|
||||
ps["feeds"].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("rss"):
|
||||
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
|
||||
_feeds[key] = data
|
||||
ps["feeds"][key] = data
|
||||
_start_poller(bot, key)
|
||||
|
||||
|
||||
@@ -411,9 +420,10 @@ async def cmd_rss(bot, message):
|
||||
if data is None:
|
||||
await bot.reply(message, f"No feed '{name}' in this channel")
|
||||
return
|
||||
_feeds[key] = data
|
||||
ps = _ps(bot)
|
||||
ps["feeds"][key] = data
|
||||
await _poll_once(bot, key, announce=True)
|
||||
data = _feeds.get(key, data)
|
||||
data = ps["feeds"].get(key, data)
|
||||
if data.get("last_error"):
|
||||
await bot.reply(message, f"{name}: error -- {data['last_error']}")
|
||||
else:
|
||||
@@ -494,7 +504,7 @@ async def cmd_rss(bot, message):
|
||||
"title": feed_title,
|
||||
}
|
||||
_save(bot, key, data)
|
||||
_feeds[key] = data
|
||||
_ps(bot)["feeds"][key] = data
|
||||
_start_poller(bot, key)
|
||||
|
||||
display = feed_title or name
|
||||
@@ -525,7 +535,7 @@ async def cmd_rss(bot, message):
|
||||
await bot.reply(message, f"No feed '{name}' in this channel")
|
||||
return
|
||||
|
||||
_stop_poller(key)
|
||||
_stop_poller(bot, key)
|
||||
_delete(bot, key)
|
||||
await bot.reply(message, f"Unsubscribed '{name}'")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user