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

@@ -38,9 +38,14 @@ _SKIP_EXTS = frozenset({
# Trailing punctuation to strip, but preserve balanced parens
_TRAIL_CHARS = set(".,;:!?)>]")
# -- Module-level state ------------------------------------------------------
# -- Per-bot state -----------------------------------------------------------
_seen: dict[str, float] = {}
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("urltitle", {
"seen": {},
})
# -- HTML parser -------------------------------------------------------------
@@ -202,21 +207,22 @@ def _fetch_title(url: str) -> tuple[str, str]:
# -- Cooldown ----------------------------------------------------------------
def _check_cooldown(url: str, cooldown: int) -> bool:
def _check_cooldown(bot, url: str, cooldown: int) -> bool:
"""Return True if the URL is within the cooldown window."""
seen = _ps(bot)["seen"]
now = time.monotonic()
last = _seen.get(url)
last = seen.get(url)
if last is not None and (now - last) < cooldown:
return True
# Prune if cache is too large
if len(_seen) >= _CACHE_MAX:
if len(seen) >= _CACHE_MAX:
cutoff = now - cooldown
stale = [k for k, v in _seen.items() if v < cutoff]
stale = [k for k, v in seen.items() if v < cutoff]
for k in stale:
del _seen[k]
del seen[k]
_seen[url] = now
seen[url] = now
return False
@@ -261,7 +267,7 @@ async def on_privmsg(bot, message):
for url in urls:
if _is_ignored_url(url, ignore_hosts):
continue
if _check_cooldown(url, cooldown):
if _check_cooldown(bot, url, cooldown):
continue
title, desc = await loop.run_in_executor(None, _fetch_title, url)