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

@@ -18,10 +18,15 @@ _MIN_INTERVAL = 60
_MAX_INTERVAL = 604800 # 7 days
_MAX_JOBS = 20
# -- Module-level tracking ---------------------------------------------------
# -- Per-bot plugin runtime state --------------------------------------------
_jobs: dict[str, dict] = {}
_tasks: dict[str, asyncio.Task] = {}
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("cron", {
"jobs": {},
"tasks": {},
})
# -- Pure helpers ------------------------------------------------------------
@@ -101,7 +106,7 @@ async def _cron_loop(bot, key: str) -> None:
"""Repeating loop: sleep, then dispatch the stored command."""
try:
while True:
data = _jobs.get(key)
data = _ps(bot)["jobs"].get(key)
if not data:
return
await asyncio.sleep(data["interval"])
@@ -118,33 +123,36 @@ async def _cron_loop(bot, key: str) -> None:
def _start_job(bot, key: str) -> None:
"""Create and track a cron task."""
existing = _tasks.get(key)
ps = _ps(bot)
existing = ps["tasks"].get(key)
if existing and not existing.done():
return
task = asyncio.create_task(_cron_loop(bot, key))
_tasks[key] = task
ps["tasks"][key] = task
def _stop_job(key: str) -> None:
def _stop_job(bot, key: str) -> None:
"""Cancel and remove a cron task."""
task = _tasks.pop(key, None)
ps = _ps(bot)
task = ps["tasks"].pop(key, None)
if task and not task.done():
task.cancel()
_jobs.pop(key, None)
ps["jobs"].pop(key, None)
# -- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Rebuild cron tasks from persisted state."""
ps = _ps(bot)
for key in bot.state.keys("cron"):
existing = _tasks.get(key)
existing = ps["tasks"].get(key)
if existing and not existing.done():
continue
data = _load(bot, key)
if data is None:
continue
_jobs[key] = data
ps["jobs"][key] = data
_start_job(bot, key)
@@ -211,7 +219,7 @@ async def cmd_cron(bot, message):
if not found_key:
await bot.reply(message, f"No cron job #{cron_id}")
return
_stop_job(found_key)
_stop_job(bot, found_key)
_delete(bot, found_key)
await bot.reply(message, f"Removed cron #{cron_id}")
return
@@ -275,7 +283,7 @@ async def cmd_cron(bot, message):
"added_by": message.nick,
}
_save(bot, key, data)
_jobs[key] = data
_ps(bot)["jobs"][key] = data
_start_job(bot, key)
fmt_interval = _format_duration(interval)