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

@@ -118,32 +118,35 @@ def _delete_saved(bot, rid: str) -> None:
bot.state.delete("remind", rid)
# ---- In-memory tracking -----------------------------------------------------
# ---- Per-bot runtime state --------------------------------------------------
# {rid: (task, target, nick, label, created, repeating)}
_reminders: dict[str, tuple[asyncio.Task, str, str, str, str, bool]] = {}
# Reverse lookup: (target, nick) -> [rid, ...]
_by_user: dict[tuple[str, str], list[str]] = {}
# Calendar-based rids (persisted)
_calendar: set[str] = set()
def _ps(bot):
"""Per-bot plugin runtime state."""
return bot._pstate.setdefault("remind", {
"reminders": {},
"by_user": {},
"calendar": set(),
})
def _cleanup(rid: str, target: str, nick: str) -> None:
def _cleanup(bot, rid: str, target: str, nick: str) -> None:
"""Remove a reminder from tracking structures."""
_reminders.pop(rid, None)
_calendar.discard(rid)
ps = _ps(bot)
ps["reminders"].pop(rid, None)
ps["calendar"].discard(rid)
ukey = (target, nick)
if ukey in _by_user:
_by_user[ukey] = [r for r in _by_user[ukey] if r != rid]
if not _by_user[ukey]:
del _by_user[ukey]
if ukey in ps["by_user"]:
ps["by_user"][ukey] = [r for r in ps["by_user"][ukey] if r != rid]
if not ps["by_user"][ukey]:
del ps["by_user"][ukey]
def _track(rid: str, task: asyncio.Task, target: str, nick: str,
def _track(bot, rid: str, task: asyncio.Task, target: str, nick: str,
label: str, created: str, repeating: bool) -> None:
"""Add a reminder to in-memory tracking."""
_reminders[rid] = (task, target, nick, label, created, repeating)
_by_user.setdefault((target, nick), []).append(rid)
ps = _ps(bot)
ps["reminders"][rid] = (task, target, nick, label, created, repeating)
ps["by_user"].setdefault((target, nick), []).append(rid)
# ---- Coroutines -------------------------------------------------------------
@@ -159,7 +162,7 @@ async def _remind_once(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError:
pass
finally:
_cleanup(rid, target, nick)
_cleanup(bot, rid, target, nick)
async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
@@ -174,7 +177,7 @@ async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError:
pass
finally:
_cleanup(rid, target, nick)
_cleanup(bot, rid, target, nick)
async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
@@ -191,7 +194,7 @@ async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
except asyncio.CancelledError:
pass
finally:
_cleanup(rid, target, nick)
_cleanup(bot, rid, target, nick)
async def _schedule_yearly(bot, rid: str, target: str, nick: str,
@@ -219,16 +222,17 @@ async def _schedule_yearly(bot, rid: str, target: str, nick: str,
except asyncio.CancelledError:
pass
finally:
_cleanup(rid, target, nick)
_cleanup(bot, rid, target, nick)
# ---- Restore on connect -----------------------------------------------------
def _restore(bot) -> None:
"""Restore persisted calendar reminders from bot.state."""
ps = _ps(bot)
for rid in bot.state.keys("remind"):
# Skip if already active
entry = _reminders.get(rid)
entry = ps["reminders"].get(rid)
if entry and not entry[0].done():
continue
raw = bot.state.get("remind", rid)
@@ -272,8 +276,8 @@ def _restore(bot) -> None:
else:
continue
_calendar.add(rid)
_track(rid, task, target, nick, label, created, rtype == "yearly")
ps["calendar"].add(rid)
_track(bot, rid, task, target, nick, label, created, rtype == "yearly")
@event("001")
@@ -311,12 +315,13 @@ async def cmd_remind(bot, message):
# ---- List ----------------------------------------------------------------
if sub == "list":
rids = _by_user.get(ukey, [])
ps = _ps(bot)
rids = ps["by_user"].get(ukey, [])
active = []
for rid in rids:
entry = _reminders.get(rid)
entry = ps["reminders"].get(rid)
if entry and not entry[0].done():
if rid in _calendar:
if rid in ps["calendar"]:
# Show next fire time
raw = bot.state.get("remind", rid)
if raw:
@@ -347,7 +352,7 @@ async def cmd_remind(bot, message):
if not rid:
await bot.reply(message, "Usage: !remind cancel <id>")
return
entry = _reminders.get(rid)
entry = _ps(bot)["reminders"].get(rid)
if entry and not entry[0].done() and entry[2] == nick:
entry[0].cancel()
_delete_saved(bot, rid)
@@ -397,11 +402,11 @@ async def cmd_remind(bot, message):
"created": created,
}
_save(bot, rid, data)
_calendar.add(rid)
_ps(bot)["calendar"].add(rid)
task = asyncio.create_task(
_schedule_at(bot, rid, target, nick, label, fire_utc, created),
)
_track(rid, task, target, nick, label, created, False)
_track(bot, rid, task, target, nick, label, created, False)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (at {local_str})")
return
@@ -459,12 +464,12 @@ async def cmd_remind(bot, message):
"created": created,
}
_save(bot, rid, data)
_calendar.add(rid)
_ps(bot)["calendar"].add(rid)
task = asyncio.create_task(
_schedule_yearly(bot, rid, target, nick, label, fire_utc,
month, day_raw, hour, minute, tz, created),
)
_track(rid, task, target, nick, label, created, True)
_track(bot, rid, task, target, nick, label, created, True)
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
await bot.reply(message, f"Reminder #{rid} set (yearly {month_day}, next {local_str})")
return
@@ -501,7 +506,7 @@ async def cmd_remind(bot, message):
_remind_once(bot, rid, target, nick, label, duration, created),
)
_track(rid, task, target, nick, label, created, repeating)
_track(bot, rid, task, target, nick, label, created, repeating)
kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration)
await bot.reply(message, f"Reminder #{rid} set ({kind})")