From 073659607ebbbe6d9f5b6b21b14fe601d3349d64 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 19:04:20 +0100 Subject: [PATCH] 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 --- plugins/alert.py | 102 +++++++++------- plugins/core.py | 2 +- plugins/cron.py | 34 ++++-- plugins/pastemoni.py | 55 +++++---- plugins/remind.py | 71 ++++++----- plugins/rss.py | 66 +++++----- plugins/twitch.py | 58 +++++---- plugins/urltitle.py | 24 ++-- plugins/webhook.py | 36 +++--- plugins/youtube.py | 66 +++++----- src/derp/bot.py | 6 +- src/derp/cli.py | 37 ++++-- src/derp/config.py | 80 ++++++++++++ tests/test_acl.py | 2 +- tests/test_alert.py | 112 +++++++++-------- tests/test_config.py | 172 +++++++++++++++++++++++++- tests/test_cron.py | 117 ++++++++---------- tests/test_integration.py | 2 +- tests/test_paste_overflow.py | 2 +- tests/test_pastemoni.py | 132 ++++++++------------ tests/test_plugin.py | 8 +- tests/test_remind.py | 229 ++++++++++++++++------------------- tests/test_rss.py | 84 ++++++------- tests/test_twitch.py | 86 +++++++------ tests/test_urltitle.py | 25 ++-- tests/test_webhook.py | 36 +++--- tests/test_youtube.py | 78 ++++++------ 27 files changed, 987 insertions(+), 735 deletions(-) diff --git a/plugins/alert.py b/plugins/alert.py index 2cd379f..e20a6ff 100644 --- a/plugins/alert.py +++ b/plugins/alert.py @@ -77,12 +77,19 @@ _DEVTO_API = "https://dev.to/api/articles" _MEDIUM_FEED_URL = "https://medium.com/feed/tag" _HUGGINGFACE_API = "https://huggingface.co/api/models" -# -- Module-level tracking --------------------------------------------------- +# -- Per-bot plugin runtime state -------------------------------------------- -_pollers: dict[str, asyncio.Task] = {} -_subscriptions: dict[str, dict] = {} -_errors: dict[str, dict[str, int]] = {} -_poll_count: dict[str, int] = {} + +def _ps(bot): + """Per-bot plugin runtime state.""" + return bot._pstate.setdefault("alert", { + "pollers": {}, + "subs": {}, + "errors": {}, + "poll_count": {}, + "db_conn": None, + "db_path": "data/alert_history.db", + }) # -- Concurrent fetch helper ------------------------------------------------- @@ -121,18 +128,16 @@ def _fetch_many(targets, *, build_req, timeout, parse): # -- History database -------------------------------------------------------- -_DB_PATH = Path("data/alert_history.db") -_conn: sqlite3.Connection | None = None - -def _db() -> sqlite3.Connection: +def _db(bot) -> sqlite3.Connection: """Lazy-init the history database connection and schema.""" - global _conn - if _conn is not None: - return _conn - _DB_PATH.parent.mkdir(parents=True, exist_ok=True) - _conn = sqlite3.connect(str(_DB_PATH)) - _conn.execute(""" + ps = _ps(bot) + if ps["db_conn"] is not None: + return ps["db_conn"] + db_path = Path(ps.get("db_path", "data/alert_history.db")) + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute(""" CREATE TABLE IF NOT EXISTS results ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, @@ -152,34 +157,35 @@ def _db() -> sqlite3.Connection: ("extra", "''"), ]: try: - _conn.execute( + conn.execute( f"ALTER TABLE results ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}" ) except sqlite3.OperationalError: pass # column already exists - _conn.execute( + conn.execute( "CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)" ) - _conn.execute( + conn.execute( "CREATE INDEX IF NOT EXISTS idx_results_short_id ON results(short_id)" ) # Backfill short_id for rows that predate the column - for row_id, backend, item_id in _conn.execute( + for row_id, backend, item_id in conn.execute( "SELECT id, backend, item_id FROM results WHERE short_id = ''" ).fetchall(): - _conn.execute( + conn.execute( "UPDATE results SET short_id = ? WHERE id = ?", (_make_short_id(backend, item_id), row_id), ) - _conn.commit() - return _conn + conn.commit() + ps["db_conn"] = conn + return conn -def _save_result(channel: str, alert: str, backend: str, item: dict, +def _save_result(bot, channel: str, alert: str, backend: str, item: dict, short_url: str = "") -> str: """Persist a matched result to the history database. Returns short_id.""" short_id = _make_short_id(backend, item.get("id", "")) - db = _db() + db = _db(bot) db.execute( "INSERT INTO results" " (channel, alert, backend, item_id, title, url, date, found_at," @@ -1814,19 +1820,20 @@ def _delete(bot, key: str) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None: """Single poll cycle for one alert subscription (all backends).""" - data = _subscriptions.get(key) + ps = _ps(bot) + data = ps["subs"].get(key) if data is None: data = _load(bot, key) if data is None: return - _subscriptions[key] = data + ps["subs"][key] = data keyword = data["keyword"] now = datetime.now(timezone.utc).isoformat() data["last_poll"] = now - cycle = _poll_count[key] = _poll_count.get(key, 0) + 1 - tag_errors = _errors.setdefault(key, {}) + cycle = ps["poll_count"][key] = ps["poll_count"].get(key, 0) + 1 + tag_errors = ps["errors"].setdefault(key, {}) loop = asyncio.get_running_loop() for tag, backend in _BACKENDS.items(): @@ -1917,7 +1924,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: except Exception: pass short_id = _save_result( - channel, name, tag, item, short_url=short_url, + bot, channel, name, tag, item, short_url=short_url, ) title = item["title"] or "(no title)" extra = item.get("extra", "") @@ -1938,7 +1945,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: seen_list = seen_list[-_MAX_SEEN:] data.setdefault("seen", {})[tag] = seen_list - _subscriptions[key] = data + ps["subs"][key] = data _save(bot, key, data) @@ -1946,7 +1953,7 @@ async def _poll_loop(bot, key: str) -> None: """Infinite poll loop for one alert subscription.""" try: while True: - data = _subscriptions.get(key) or _load(bot, key) + data = _ps(bot)["subs"].get(key) or _load(bot, key) if data is None: return interval = data.get("interval", _DEFAULT_INTERVAL) @@ -1958,35 +1965,38 @@ 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() - _subscriptions.pop(key, None) - _errors.pop(key, None) - _poll_count.pop(key, None) + ps["subs"].pop(key, None) + ps["errors"].pop(key, None) + ps["poll_count"].pop(key, None) # -- Restore on connect ----------------------------------------------------- def _restore(bot) -> None: """Rebuild pollers from persisted state.""" + ps = _ps(bot) for key in bot.state.keys("alert"): - 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 - _subscriptions[key] = data + ps["subs"][key] = data _start_poller(bot, key) @@ -2056,9 +2066,9 @@ async def cmd_alert(bot, message): if data is None: await bot.reply(message, f"No alert '{name}' in this channel") return - _subscriptions[key] = data + _ps(bot)["subs"][key] = data await _poll_once(bot, key, announce=True) - data = _subscriptions.get(key, data) + data = _ps(bot)["subs"].get(key, data) errs = data.get("last_errors", {}) if errs: tags = ", ".join(sorted(errs)) @@ -2087,7 +2097,7 @@ async def cmd_alert(bot, message): limit = max(1, min(int(parts[3]), 20)) except ValueError: limit = 5 - db = _db() + db = _db(bot) rows = db.execute( "SELECT id, backend, title, url, date, found_at, short_id," " short_url, extra FROM results" @@ -2141,7 +2151,7 @@ async def cmd_alert(bot, message): return short_id = parts[2].lower() channel = message.target - db = _db() + db = _db(bot) row = db.execute( "SELECT alert, backend, title, url, date, found_at, short_id," " extra" @@ -2216,7 +2226,7 @@ async def cmd_alert(bot, message): "seen": {}, } _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data # Seed seen IDs in background (silent poll), then start the poller async def _seed(): @@ -2251,7 +2261,7 @@ async def cmd_alert(bot, message): await bot.reply(message, f"No alert '{name}' in this channel") return - _stop_poller(key) + _stop_poller(bot, key) _delete(bot, key) await bot.reply(message, f"Removed '{name}'") return diff --git a/plugins/core.py b/plugins/core.py index c2ca08b..ceaaa9f 100644 --- a/plugins/core.py +++ b/plugins/core.py @@ -58,7 +58,7 @@ async def cmd_help(bot, message): @command("version", help="Show bot version") async def cmd_version(bot, message): """Report the running version.""" - await bot.reply(message, f"derp {__version__}") + await bot.reply(message, f"derp {__version__} ({bot.name})") @command("uptime", help="Show how long the bot has been running") diff --git a/plugins/cron.py b/plugins/cron.py index f0cbc98..75a9ab7 100644 --- a/plugins/cron.py +++ b/plugins/cron.py @@ -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) diff --git a/plugins/pastemoni.py b/plugins/pastemoni.py index 0fbd801..3f40abf 100644 --- a/plugins/pastemoni.py +++ b/plugins/pastemoni.py @@ -28,11 +28,15 @@ _MAX_MONITORS = 20 _MAX_SNIPPET_LEN = 80 _MAX_TITLE_LEN = 60 -# -- Module-level tracking --------------------------------------------------- +# -- Per-bot runtime state --------------------------------------------------- -_pollers: dict[str, asyncio.Task] = {} -_monitors: dict[str, dict] = {} -_errors: dict[str, int] = {} +def _ps(bot): + """Per-bot plugin runtime state.""" + return bot._pstate.setdefault("pastemoni", { + "pollers": {}, + "monitors": {}, + "errors": {}, + }) # -- Pure helpers ------------------------------------------------------------ @@ -239,12 +243,13 @@ _BACKENDS: dict[str, callable] = { async def _poll_once(bot, key: str, announce: bool = True) -> None: """Single poll cycle for one monitor (all backends).""" - data = _monitors.get(key) + ps = _ps(bot) + data = ps["monitors"].get(key) if data is None: data = _load(bot, key) if data is None: return - _monitors[key] = data + ps["monitors"][key] = data keyword = data["keyword"] now = datetime.now(timezone.utc).isoformat() @@ -294,11 +299,11 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: data.setdefault("seen", {})[tag] = seen_list if had_success: - _errors[key] = 0 + ps["errors"][key] = 0 else: - _errors[key] = _errors.get(key, 0) + 1 + ps["errors"][key] = ps["errors"].get(key, 0) + 1 - _monitors[key] = data + ps["monitors"][key] = data _save(bot, key, data) @@ -306,11 +311,12 @@ async def _poll_loop(bot, key: str) -> None: """Infinite poll loop for one monitor.""" try: while True: - data = _monitors.get(key) or _load(bot, key) + ps = _ps(bot) + data = ps["monitors"].get(key) or _load(bot, key) if data is None: return interval = data.get("interval", _DEFAULT_INTERVAL) - errs = _errors.get(key, 0) + errs = ps["errors"].get(key, 0) if errs >= 5: interval = min(interval * 2, _MAX_INTERVAL) await asyncio.sleep(interval) @@ -321,34 +327,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() - _monitors.pop(key, None) - _errors.pop(key, 0) + ps["monitors"].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("pastemoni"): - 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 - _monitors[key] = data + ps["monitors"][key] = data _start_poller(bot, key) @@ -417,9 +426,9 @@ async def cmd_pastemoni(bot, message): if data is None: await bot.reply(message, f"No monitor '{name}' in this channel") return - _monitors[key] = data + _ps(bot)["monitors"][key] = data await _poll_once(bot, key, announce=True) - data = _monitors.get(key, data) + data = _ps(bot)["monitors"].get(key, data) errs = data.get("last_errors", {}) if errs: tags = ", ".join(sorted(errs)) @@ -480,7 +489,7 @@ async def cmd_pastemoni(bot, message): "seen": {}, } _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def _seed(): await _poll_once(bot, key, announce=False) @@ -514,7 +523,7 @@ async def cmd_pastemoni(bot, message): await bot.reply(message, f"No monitor '{name}' in this channel") return - _stop_poller(key) + _stop_poller(bot, key) _delete(bot, key) await bot.reply(message, f"Removed '{name}'") return diff --git a/plugins/remind.py b/plugins/remind.py index 8906bec..aa96588 100644 --- a/plugins/remind.py +++ b/plugins/remind.py @@ -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 ") 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})") diff --git a/plugins/rss.py b/plugins/rss.py index 4e9b428..8c1399a 100644 --- a/plugins/rss.py +++ b/plugins/rss.py @@ -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 diff --git a/plugins/twitch.py b/plugins/twitch.py index 283ba10..8b403a1 100644 --- a/plugins/twitch.py +++ b/plugins/twitch.py @@ -23,11 +23,15 @@ _FETCH_TIMEOUT = 10 _MAX_TITLE_LEN = 80 _MAX_STREAMERS = 20 -# -- Module-level tracking --------------------------------------------------- +# -- Per-bot runtime state --------------------------------------------------- -_pollers: dict[str, asyncio.Task] = {} -_streamers: dict[str, dict] = {} -_errors: dict[str, int] = {} +def _ps(bot): + """Per-bot plugin runtime state.""" + return bot._pstate.setdefault("twitch", { + "pollers": {}, + "streamers": {}, + "errors": {}, + }) # -- Pure helpers ------------------------------------------------------------ @@ -149,12 +153,13 @@ def _delete(bot, key: str) -> None: async def _poll_once(bot, key: str, announce: bool = True) -> None: """Single poll cycle for one Twitch streamer.""" - data = _streamers.get(key) + ps = _ps(bot) + data = ps["streamers"].get(key) if data is None: data = _load(bot, key) if data is None: return - _streamers[key] = data + ps["streamers"][key] = data login = data["login"] @@ -166,13 +171,13 @@ 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 - _streamers[key] = data + ps["errors"][key] = ps["errors"].get(key, 0) + 1 + ps["streamers"][key] = data _save(bot, key, data) return data["last_error"] = "" - _errors[key] = 0 + ps["errors"][key] = 0 was_live = data.get("was_live", False) old_stream_id = data.get("stream_id", "") @@ -202,7 +207,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: else: data["was_live"] = False - _streamers[key] = data + ps["streamers"][key] = data _save(bot, key, data) @@ -210,11 +215,12 @@ async def _poll_loop(bot, key: str) -> None: """Infinite poll loop for one Twitch streamer.""" try: while True: - data = _streamers.get(key) or _load(bot, key) + ps = _ps(bot) + data = ps["streamers"].get(key) or _load(bot, key) if data is None: return interval = data.get("interval", _DEFAULT_INTERVAL) - errs = _errors.get(key, 0) + errs = ps["errors"].get(key, 0) if errs >= 5: interval = min(interval * 2, _MAX_INTERVAL) await asyncio.sleep(interval) @@ -225,34 +231,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() - _streamers.pop(key, None) - _errors.pop(key, 0) + ps["streamers"].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("twitch"): - 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 - _streamers[key] = data + ps["streamers"][key] = data _start_poller(bot, key) @@ -329,9 +338,10 @@ async def cmd_twitch(bot, message): if data is None: await bot.reply(message, f"No streamer '{name}' in this channel") return - _streamers[key] = data + ps = _ps(bot) + ps["streamers"][key] = data await _poll_once(bot, key, announce=True) - data = _streamers.get(key, data) + data = ps["streamers"].get(key, data) if data.get("last_error"): await bot.reply(message, f"{name}: error -- {data['last_error']}") elif data.get("was_live"): @@ -417,7 +427,7 @@ async def cmd_twitch(bot, message): "last_error": "", } _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data _start_poller(bot, key) reply = f"Following '{name}' ({display_name})" @@ -446,7 +456,7 @@ async def cmd_twitch(bot, message): await bot.reply(message, f"No streamer '{name}' in this channel") return - _stop_poller(key) + _stop_poller(bot, key) _delete(bot, key) await bot.reply(message, f"Unfollowed '{name}'") return diff --git a/plugins/urltitle.py b/plugins/urltitle.py index fafda87..0c7996f 100644 --- a/plugins/urltitle.py +++ b/plugins/urltitle.py @@ -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) diff --git a/plugins/webhook.py b/plugins/webhook.py index 378c514..dc50b41 100644 --- a/plugins/webhook.py +++ b/plugins/webhook.py @@ -14,9 +14,15 @@ from derp.plugin import command, event log = logging.getLogger(__name__) _MAX_BODY = 65536 # 64 KB -_server: asyncio.Server | None = None -_request_count: int = 0 -_started: float = 0.0 + + +def _ps(bot): + """Per-bot plugin runtime state.""" + return bot._pstate.setdefault("webhook", { + "server": None, + "request_count": 0, + "started": 0.0, + }) def _verify_signature(secret: str, body: bytes, signature: str) -> bool: @@ -47,7 +53,7 @@ async def _handle_request(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, bot, secret: str) -> None: """Parse one HTTP request and dispatch to IRC.""" - global _request_count + ps = _ps(bot) try: # Read request line @@ -117,7 +123,7 @@ async def _handle_request(reader: asyncio.StreamReader, else: await bot.send(channel, text) - _request_count += 1 + ps["request_count"] += 1 writer.write(_http_response(200, "OK", "sent")) log.info("webhook: relayed to %s (%d bytes)", channel, len(text)) @@ -140,9 +146,9 @@ async def _handle_request(reader: asyncio.StreamReader, @event("001") async def on_connect(bot, message): """Start the webhook HTTP server on connect (if enabled).""" - global _server, _started, _request_count + ps = _ps(bot) - if _server is not None: + if ps["server"] is not None: return # already running cfg = bot.config.get("webhook", {}) @@ -157,9 +163,9 @@ async def on_connect(bot, message): await _handle_request(reader, writer, bot, secret) try: - _server = await asyncio.start_server(handler, host, port) - _started = time.monotonic() - _request_count = 0 + ps["server"] = await asyncio.start_server(handler, host, port) + ps["started"] = time.monotonic() + ps["request_count"] = 0 log.info("webhook: listening on %s:%d", host, port) except OSError as exc: log.error("webhook: failed to bind %s:%d: %s", host, port, exc) @@ -168,18 +174,20 @@ async def on_connect(bot, message): @command("webhook", help="Show webhook listener status", admin=True) async def cmd_webhook(bot, message): """Display webhook server status.""" - if _server is None: + ps = _ps(bot) + + if ps["server"] is None: await bot.reply(message, "Webhook: not running") return - socks = _server.sockets + socks = ps["server"].sockets if socks: addr = socks[0].getsockname() address = f"{addr[0]}:{addr[1]}" else: address = "unknown" - elapsed = int(time.monotonic() - _started) + elapsed = int(time.monotonic() - ps["started"]) hours, rem = divmod(elapsed, 3600) minutes, secs = divmod(rem, 60) parts = [] @@ -192,5 +200,5 @@ async def cmd_webhook(bot, message): await bot.reply( message, - f"Webhook: {address} | {_request_count} requests | up {uptime}", + f"Webhook: {address} | {ps['request_count']} requests | up {uptime}", ) diff --git a/plugins/youtube.py b/plugins/youtube.py index 1c93fae..3f0c707 100644 --- a/plugins/youtube.py +++ b/plugins/youtube.py @@ -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 diff --git a/src/derp/bot.py b/src/derp/bot.py index fcd1d61..b89c517 100644 --- a/src/derp/bot.py +++ b/src/derp/bot.py @@ -77,9 +77,11 @@ class _TokenBucket: class Bot: """IRC bot: ties connection, config, and plugins together.""" - def __init__(self, config: dict, registry: PluginRegistry) -> None: + def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None: + self.name = name self.config = config self.registry = registry + self._pstate: dict = {} # per-bot plugin runtime state self.conn = IRCConnection( host=config["server"]["host"], port=config["server"]["port"], @@ -98,7 +100,7 @@ class Bot: self._opers: set[str] = set() # hostmasks of known IRC operators self._caps: set[str] = set() # negotiated IRCv3 caps self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel - self.state = StateStore() + self.state = StateStore(f"data/state-{name}.db") # Rate limiter: default 2 msg/sec, burst of 5 rate_cfg = config.get("bot", {}) self._bucket = _TokenBucket( diff --git a/src/derp/cli.py b/src/derp/cli.py index 4b2dbd6..0670aac 100644 --- a/src/derp/cli.py +++ b/src/derp/cli.py @@ -9,7 +9,7 @@ import sys from derp import __version__ from derp.bot import Bot -from derp.config import resolve_config +from derp.config import build_server_configs, resolve_config from derp.log import JsonFormatter from derp.plugin import PluginRegistry @@ -56,14 +56,14 @@ def build_parser() -> argparse.ArgumentParser: return p -def _run(bot: Bot) -> None: - """Run the bot event loop with graceful SIGTERM handling.""" +def _run(bots: list[Bot]) -> None: + """Run all bots concurrently with graceful SIGTERM handling.""" import signal async def _start_with_signal(): loop = asyncio.get_running_loop() - loop.add_signal_handler(signal.SIGTERM, _shutdown, bot) - await bot.start() + loop.add_signal_handler(signal.SIGTERM, _shutdown, bots) + await asyncio.gather(*(bot.start() for bot in bots)) try: asyncio.run(_start_with_signal()) @@ -71,11 +71,12 @@ def _run(bot: Bot) -> None: logging.getLogger("derp").info("interrupted, shutting down") -def _shutdown(bot: Bot) -> None: - """Signal handler: stop the bot loop so cProfile can flush.""" +def _shutdown(bots: list[Bot]) -> None: + """Signal handler: stop all bot loops so cProfile can flush.""" logging.getLogger("derp").info("SIGTERM received, shutting down") - bot._running = False - asyncio.get_running_loop().create_task(bot.conn.close()) + for bot in bots: + bot._running = False + asyncio.get_running_loop().create_task(bot.conn.close()) def _dump_tracemalloc(log: logging.Logger, path: str, limit: int = 25) -> None: @@ -121,9 +122,19 @@ def main(argv: list[str] | None = None) -> int: log = logging.getLogger("derp") log.info("derp %s starting", __version__) + server_configs = build_server_configs(config) registry = PluginRegistry() - bot = Bot(config, registry) - bot.load_plugins() + + bots: list[Bot] = [] + for name, srv_config in server_configs.items(): + bot = Bot(name, srv_config, registry) + bots.append(bot) + + # Load plugins once (shared registry) + bots[0].load_plugins() + + names = ", ".join(b.name for b in bots) + log.info("servers: %s", names) if args.tracemalloc: import tracemalloc @@ -135,10 +146,10 @@ def main(argv: list[str] | None = None) -> int: import cProfile log.info("cProfile enabled, output: %s", args.cprofile) - cProfile.runctx("_run(bot)", globals(), {"bot": bot, "_run": _run}, args.cprofile) + cProfile.runctx("_run(bots)", globals(), {"bots": bots, "_run": _run}, args.cprofile) log.info("profile saved to %s", args.cprofile) else: - _run(bot) + _run(bots) if args.tracemalloc: _dump_tracemalloc(log, "data/derp.malloc") diff --git a/src/derp/config.py b/src/derp/config.py index 120a604..15b6dce 100644 --- a/src/derp/config.py +++ b/src/derp/config.py @@ -75,3 +75,83 @@ def resolve_config(path: str | None) -> dict: if p and p.is_file(): return load(p) return DEFAULTS.copy() + + +def _server_name(host: str) -> str: + """Derive a short server name from a hostname. + + ``irc.libera.chat`` -> ``libera``, ``chat.freenode.net`` -> ``freenode``. + Falls back to the full host if no suitable label is found. + """ + parts = host.split(".") + for part in parts: + if part not in ("irc", "chat", ""): + return part + return host + + +_SERVER_KEYS = set(DEFAULTS["server"]) +_BOT_KEYS = set(DEFAULTS["bot"]) + + +def build_server_configs(raw: dict) -> dict[str, dict]: + """Build per-server config dicts from a merged config. + + Supports two layouts: + + **Legacy** (``[server]`` section, no ``[servers]``): + Returns a single-entry dict with the server name derived from the + hostname. Existing config files work unchanged. + + **Multi-server** (``[servers.]`` sections): + Each ``[servers.]`` block may contain both server-level keys + (host, port, tls, nick, ...) and bot-level overrides (prefix, + channels, admins, ...). Unset keys inherit from the top-level + ``[bot]`` and ``[server]`` defaults. + + Returns ``{name: config_dict}`` where each *config_dict* has the + canonical shape ``{"server": {...}, "bot": {...}, "channels": {...}, + "webhook": {...}, "logging": {...}}``. + """ + servers_section = raw.get("servers") + + # -- Legacy single-server layout -- + if not servers_section or not isinstance(servers_section, dict): + name = _server_name(raw.get("server", {}).get("host", "default")) + return {name: raw} + + # -- Multi-server layout -- + # Shared top-level sections + shared_bot = raw.get("bot", {}) + shared_server = raw.get("server", {}) + shared_channels = raw.get("channels", {}) + shared_webhook = raw.get("webhook", {}) + shared_logging = raw.get("logging", {}) + + result: dict[str, dict] = {} + for name, block in servers_section.items(): + if not isinstance(block, dict): + continue + + # Separate server keys from bot-override keys + srv: dict = {} + bot_overrides: dict = {} + extra: dict = {} + for key, val in block.items(): + if key in _SERVER_KEYS: + srv[key] = val + elif key in _BOT_KEYS: + bot_overrides[key] = val + else: + extra[key] = val + + cfg = { + "server": _merge(DEFAULTS["server"], _merge(shared_server, srv)), + "bot": _merge(DEFAULTS["bot"], _merge(shared_bot, bot_overrides)), + "channels": _merge(shared_channels, extra.get("channels", {})), + "webhook": _merge(DEFAULTS["webhook"], shared_webhook), + "logging": _merge(DEFAULTS["logging"], shared_logging), + } + result[name] = cfg + + return result diff --git a/tests/test_acl.py b/tests/test_acl.py index 844a4f0..6c16db4 100644 --- a/tests/test_acl.py +++ b/tests/test_acl.py @@ -63,7 +63,7 @@ class _Harness: }, } self.registry = PluginRegistry() - self.bot = Bot(config, self.registry) + self.bot = Bot("test", config, self.registry) self.conn = _MockConnection() self.bot.conn = self.conn # type: ignore[assignment] self.registry.load_plugin(Path("plugins/core.py")) diff --git a/tests/test_alert.py b/tests/test_alert.py index 0611c04..4a691d5 100644 --- a/tests/test_alert.py +++ b/tests/test_alert.py @@ -21,11 +21,10 @@ from plugins.alert import ( # noqa: E402 _MAX_SEEN, _compact_num, _delete, - _errors, _extract_videos, _load, _poll_once, - _pollers, + _ps, _restore, _save, _save_result, @@ -35,7 +34,6 @@ from plugins.alert import ( # noqa: E402 _start_poller, _state_key, _stop_poller, - _subscriptions, _truncate, _validate_name, cmd_alert, @@ -169,6 +167,7 @@ class _FakeBot: self.actions: list[tuple[str, str]] = [] self.replied: list[str] = [] self.state = _FakeState() + self._pstate: dict = {} self.registry = _FakeRegistry() self._admin = admin @@ -205,14 +204,18 @@ def _pm(text: str, nick: str = "alice") -> Message: ) -def _clear() -> None: - """Reset module-level state between tests.""" - for task in _pollers.values(): +def _clear(bot=None) -> None: + """Reset per-bot plugin state between tests.""" + if bot is None: + return + ps = _ps(bot) + for task in ps["pollers"].values(): if task and not task.done(): task.cancel() - _pollers.clear() - _subscriptions.clear() - _errors.clear() + ps["pollers"].clear() + ps["subs"].clear() + ps["errors"].clear() + ps["poll_count"].clear() def _fake_yt(keyword): @@ -595,8 +598,8 @@ class TestCmdAlertAdd: assert len(data["seen"]["yt"]) == 2 assert len(data["seen"]["tw"]) == 2 assert len(data["seen"]["sx"]) == 2 - assert "#test:mc-speed" in _pollers - _stop_poller("#test:mc-speed") + assert "#test:mc-speed" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:mc-speed") await asyncio.sleep(0) asyncio.run(inner()) @@ -644,7 +647,7 @@ class TestCmdAlertAdd: with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): await cmd_alert(bot, _msg("!alert add dupe other keyword")) assert "already exists" in bot.replied[0] - _stop_poller("#test:dupe") + _stop_poller(bot, "#test:dupe") await asyncio.sleep(0) asyncio.run(inner()) @@ -681,7 +684,7 @@ class TestCmdAlertAdd: assert len(data["seen"]["yt"]) == 2 assert len(data["seen"].get("tw", [])) == 0 assert len(data["seen"]["sx"]) == 2 - _stop_poller("#test:partial") + _stop_poller(bot, "#test:partial") await asyncio.sleep(0) asyncio.run(inner()) @@ -704,7 +707,7 @@ class TestCmdAlertDel: await cmd_alert(bot, _msg("!alert del todel")) assert "Removed 'todel'" in bot.replied[0] assert _load(bot, "#test:todel") is None - assert "#test:todel" not in _pollers + assert "#test:todel" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) @@ -896,7 +899,7 @@ class TestPollOnce: } key = "#test:poll" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -933,7 +936,7 @@ class TestPollOnce: } key = "#test:dedup" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -953,7 +956,7 @@ class TestPollOnce: } key = "#test:partial" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data backends = {"yt": _fake_yt_error, "tw": _fake_tw, "sx": _fake_sx} async def inner(): @@ -965,7 +968,7 @@ class TestPollOnce: assert len(tw_msgs) == 2 assert len(sx_msgs) == 2 # Error counter should be incremented for yt backend - assert _errors[key]["yt"] == 1 + assert _ps(bot)["errors"][key]["yt"] == 1 updated = _load(bot, key) assert "yt" in updated.get("last_errors", {}) @@ -981,7 +984,7 @@ class TestPollOnce: } key = "#test:quiet" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -1012,7 +1015,7 @@ class TestPollOnce: } key = "#test:cap" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", {"yt": fake_many, "tw": _fake_tw}): @@ -1034,13 +1037,13 @@ class TestPollOnce: } key = "#test:allerr" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data backends = {"yt": _fake_yt_error, "tw": _fake_tw_error, "sx": _fake_sx_error} async def inner(): with patch.object(_mod, "_BACKENDS", backends): await _poll_once(bot, key, announce=True) - assert all(v == 1 for v in _errors[key].values()) + assert all(v == 1 for v in _ps(bot)["errors"][key].values()) assert len(bot.sent) == 0 asyncio.run(inner()) @@ -1058,13 +1061,13 @@ class TestPollOnce: } key = "#test:clrerr" _save(bot, key, data) - _subscriptions[key] = data - _errors[key] = {"yt": 3, "tw": 3, "sx": 3} + _ps(bot)["subs"][key] = data + _ps(bot)["errors"][key] = {"yt": 3, "tw": 3, "sx": 3} async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): await _poll_once(bot, key, announce=True) - assert all(v == 0 for v in _errors[key].values()) + assert all(v == 0 for v in _ps(bot)["errors"][key].values()) updated = _load(bot, key) assert updated.get("last_errors", {}) == {} @@ -1088,10 +1091,11 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:restored" in _pollers - task = _pollers["#test:restored"] + ps = _ps(bot) + assert "#test:restored" in ps["pollers"] + task = ps["pollers"]["#test:restored"] assert not task.done() - _stop_poller("#test:restored") + _stop_poller(bot, "#test:restored") await asyncio.sleep(0) asyncio.run(inner()) @@ -1107,10 +1111,11 @@ class TestRestore: _save(bot, "#test:active", data) async def inner(): + ps = _ps(bot) dummy = asyncio.create_task(asyncio.sleep(9999)) - _pollers["#test:active"] = dummy + ps["pollers"]["#test:active"] = dummy _restore(bot) - assert _pollers["#test:active"] is dummy + assert ps["pollers"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) @@ -1127,14 +1132,15 @@ class TestRestore: _save(bot, "#test:done", data) async def inner(): + ps = _ps(bot) done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _pollers["#test:done"] = done_task + ps["pollers"]["#test:done"] = done_task _restore(bot) - new_task = _pollers["#test:done"] + new_task = ps["pollers"]["#test:done"] assert new_task is not done_task assert not new_task.done() - _stop_poller("#test:done") + _stop_poller(bot, "#test:done") await asyncio.sleep(0) asyncio.run(inner()) @@ -1146,7 +1152,7 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:bad" not in _pollers + assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) @@ -1163,8 +1169,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:conn" in _pollers - _stop_poller("#test:conn") + assert "#test:conn" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:conn") await asyncio.sleep(0) asyncio.run(inner()) @@ -1185,16 +1191,17 @@ class TestPollerManagement: } key = "#test:mgmt" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): + ps = _ps(bot) _start_poller(bot, key) - assert key in _pollers - assert not _pollers[key].done() - _stop_poller(key) + assert key in ps["pollers"] + assert not ps["pollers"][key].done() + _stop_poller(bot, key) await asyncio.sleep(0) - assert key not in _pollers - assert key not in _subscriptions + assert key not in ps["pollers"] + assert key not in ps["subs"] asyncio.run(inner()) @@ -1208,21 +1215,22 @@ class TestPollerManagement: } key = "#test:idem" _save(bot, key, data) - _subscriptions[key] = data + _ps(bot)["subs"][key] = data async def inner(): + ps = _ps(bot) _start_poller(bot, key) - first = _pollers[key] + first = ps["pollers"][key] _start_poller(bot, key) - assert _pollers[key] is first - _stop_poller(key) + assert ps["pollers"][key] is first + _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): - _clear() - _stop_poller("#test:nonexistent") + bot = _FakeBot() + _stop_poller(bot, "#test:nonexistent") # --------------------------------------------------------------------------- @@ -1299,7 +1307,7 @@ class TestExtraInHistory: } _save(bot, "#test:hist", data) # Insert a result with extra metadata - _save_result("#test", "hist", "hn", { + _save_result(bot, "#test", "hist", "hn", { "id": "h1", "title": "Cool HN Post", "url": "https://hn.example.com/1", "date": "2026-01-15", "extra": "42pt 10c", }) @@ -1321,7 +1329,7 @@ class TestExtraInHistory: "interval": 300, "seen": {}, "last_poll": "", "last_error": "", } _save(bot, "#test:hist2", data) - _save_result("#test", "hist2", "yt", { + _save_result(bot, "#test", "hist2", "yt", { "id": "y1", "title": "Plain Video", "url": "https://yt.example.com/1", "date": "", "extra": "", }) @@ -1348,7 +1356,7 @@ class TestExtraInInfo: "interval": 300, "seen": {}, "last_poll": "", "last_error": "", } _save(bot, "#test:inf", data) - short_id = _save_result("#test", "inf", "gh", { + short_id = _save_result(bot, "#test", "inf", "gh", { "id": "g1", "title": "cool/repo: A cool project", "url": "https://github.com/cool/repo", "date": "2026-01-10", "extra": "42* 5fk", @@ -1370,7 +1378,7 @@ class TestExtraInInfo: "interval": 300, "seen": {}, "last_poll": "", "last_error": "", } _save(bot, "#test:inf2", data) - short_id = _save_result("#test", "inf2", "yt", { + short_id = _save_result(bot, "#test", "inf2", "yt", { "id": "y2", "title": "Some Video", "url": "https://youtube.com/watch?v=y2", "date": "", "extra": "", diff --git a/tests/test_config.py b/tests/test_config.py index 72dadc1..a24294c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,14 @@ from pathlib import Path import pytest -from derp.config import DEFAULTS, _merge, load, resolve_config +from derp.config import ( + DEFAULTS, + _merge, + _server_name, + build_server_configs, + load, + resolve_config, +) class TestMerge: @@ -110,3 +117,166 @@ class TestResolveConfig: original = copy.deepcopy(DEFAULTS) resolve_config(None) assert DEFAULTS == original + + +# --------------------------------------------------------------------------- +# _server_name +# --------------------------------------------------------------------------- + + +class TestServerName: + """Test hostname-to-short-name derivation.""" + + def test_libera(self): + assert _server_name("irc.libera.chat") == "libera" + + def test_oftc(self): + assert _server_name("irc.oftc.net") == "oftc" + + def test_freenode(self): + assert _server_name("chat.freenode.net") == "freenode" + + def test_plain_hostname(self): + assert _server_name("myserver") == "myserver" + + def test_empty_fallback(self): + assert _server_name("") == "" + + def test_only_common_parts(self): + assert _server_name("irc.chat.irc") == "irc.chat.irc" + + +# --------------------------------------------------------------------------- +# build_server_configs +# --------------------------------------------------------------------------- + + +class TestBuildServerConfigs: + """Test multi-server config builder.""" + + def test_legacy_single_server(self): + """Legacy [server] config returns a single-entry dict.""" + raw = _merge(DEFAULTS, { + "server": {"host": "irc.libera.chat", "nick": "testbot"}, + }) + result = build_server_configs(raw) + assert list(result.keys()) == ["libera"] + assert result["libera"]["server"]["nick"] == "testbot" + + def test_legacy_preserves_full_config(self): + """Legacy mode passes through the entire config dict.""" + raw = _merge(DEFAULTS, { + "server": {"host": "irc.oftc.net"}, + "bot": {"prefix": "."}, + }) + result = build_server_configs(raw) + cfg = result["oftc"] + assert cfg["bot"]["prefix"] == "." + assert cfg["server"]["host"] == "irc.oftc.net" + + def test_multi_server_creates_entries(self): + """Multiple [servers.*] blocks produce multiple entries.""" + raw = { + "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, + "servers": { + "libera": {"host": "irc.libera.chat", "port": 6697, + "nick": "derp", "channels": ["#test"]}, + "oftc": {"host": "irc.oftc.net", "port": 6697, + "nick": "derpbot", "channels": ["#derp"]}, + }, + } + result = build_server_configs(raw) + assert set(result.keys()) == {"libera", "oftc"} + + def test_multi_server_key_separation(self): + """Server keys and bot keys are separated correctly.""" + raw = { + "servers": { + "test": { + "host": "irc.test.net", "port": 6667, "tls": False, + "nick": "bot", + "prefix": ".", "channels": ["#a"], + "admins": ["*!*@admin"], + }, + }, + } + result = build_server_configs(raw) + cfg = result["test"] + # Server keys + assert cfg["server"]["host"] == "irc.test.net" + assert cfg["server"]["port"] == 6667 + assert cfg["server"]["nick"] == "bot" + # Bot keys (overrides) + assert cfg["bot"]["prefix"] == "." + assert cfg["bot"]["channels"] == ["#a"] + assert cfg["bot"]["admins"] == ["*!*@admin"] + + def test_multi_server_inherits_shared_bot(self): + """Per-server configs inherit shared [bot] defaults.""" + raw = { + "bot": {"prefix": ".", "admins": ["*!*@global"]}, + "servers": { + "s1": {"host": "irc.s1.net", "nick": "bot1"}, + }, + } + result = build_server_configs(raw) + cfg = result["s1"] + assert cfg["bot"]["prefix"] == "." + assert cfg["bot"]["admins"] == ["*!*@global"] + + def test_multi_server_overrides_shared_bot(self): + """Per-server bot keys override shared [bot] values.""" + raw = { + "bot": {"prefix": "!", "admins": ["*!*@global"]}, + "servers": { + "s1": {"host": "irc.s1.net", "nick": "bot1", + "prefix": ".", "admins": ["*!*@local"]}, + }, + } + result = build_server_configs(raw) + cfg = result["s1"] + assert cfg["bot"]["prefix"] == "." + assert cfg["bot"]["admins"] == ["*!*@local"] + + def test_multi_server_defaults_applied(self): + """Missing keys fall back to DEFAULTS.""" + raw = { + "servers": { + "minimal": {"host": "irc.min.net", "nick": "m"}, + }, + } + result = build_server_configs(raw) + cfg = result["minimal"] + assert cfg["server"]["tls"] is True # from DEFAULTS + assert cfg["bot"]["prefix"] == "!" # from DEFAULTS + assert cfg["bot"]["rate_limit"] == 2.0 + + def test_multi_server_shared_sections(self): + """Shared webhook/logging sections propagate to all servers.""" + raw = { + "webhook": {"enabled": True, "port": 9090}, + "logging": {"format": "json"}, + "servers": { + "a": {"host": "irc.a.net", "nick": "a"}, + "b": {"host": "irc.b.net", "nick": "b"}, + }, + } + result = build_server_configs(raw) + for name in ("a", "b"): + assert result[name]["webhook"]["enabled"] is True + assert result[name]["webhook"]["port"] == 9090 + assert result[name]["logging"]["format"] == "json" + + def test_empty_servers_section_falls_back(self): + """Empty [servers] section treated as legacy single-server.""" + raw = _merge(DEFAULTS, {"servers": {}}) + result = build_server_configs(raw) + assert len(result) == 1 + + def test_no_servers_key_is_legacy(self): + """Config without [servers] is legacy single-server mode.""" + raw = copy.deepcopy(DEFAULTS) + result = build_server_configs(raw) + assert len(result) == 1 + name = list(result.keys())[0] + assert result[name] is raw diff --git a/tests/test_cron.py b/tests/test_cron.py index 4b80ec0..9792b1b 100644 --- a/tests/test_cron.py +++ b/tests/test_cron.py @@ -20,16 +20,15 @@ from plugins.cron import ( # noqa: E402 _MAX_JOBS, _delete, _format_duration, - _jobs, _load, _make_id, _parse_duration, + _ps, _restore, _save, _start_job, _state_key, _stop_job, - _tasks, cmd_cron, on_connect, ) @@ -67,6 +66,7 @@ class _FakeBot: self.replied: list[str] = [] self.dispatched: list[Message] = [] self.state = _FakeState() + self._pstate: dict = {} self._admin = admin self.prefix = "!" @@ -99,13 +99,16 @@ def _pm(text: str, nick: str = "admin") -> Message: ) -def _clear() -> None: - """Reset module-level state between tests.""" - for task in _tasks.values(): +def _clear(bot=None) -> None: + """Reset per-bot plugin state between tests.""" + if bot is None: + return + ps = _ps(bot) + for task in ps["tasks"].values(): if task and not task.done(): task.cancel() - _tasks.clear() - _jobs.clear() + ps["tasks"].clear() + ps["jobs"].clear() # --------------------------------------------------------------------------- @@ -226,7 +229,6 @@ class TestStateHelpers: class TestCmdCronAdd: def test_add_success(self): - _clear() bot = _FakeBot() async def inner(): @@ -245,50 +247,43 @@ class TestCmdCronAdd: assert data["interval"] == 300 assert data["channel"] == "#ops" # Verify task started - assert len(_tasks) == 1 - _clear() + assert len(_ps(bot)["tasks"]) == 1 + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) def test_add_requires_channel(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping"))) assert "Use this command in a channel" in bot.replied[0] def test_add_missing_args(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron add 5m"))) assert "Usage:" in bot.replied[0] def test_add_invalid_interval(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping"))) assert "Invalid interval" in bot.replied[0] def test_add_interval_too_short(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping"))) assert "Minimum interval" in bot.replied[0] def test_add_interval_too_long(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping"))) assert "Maximum interval" in bot.replied[0] def test_add_bad_target(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping"))) assert "Target must be a channel" in bot.replied[0] def test_add_job_limit(self): - _clear() bot = _FakeBot() for i in range(_MAX_JOBS): _save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"}) @@ -297,7 +292,6 @@ class TestCmdCronAdd: assert "limit reached" in bot.replied[0] def test_add_admin_required(self): - _clear() bot = _FakeBot(admin=False) asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping"))) # The @command(admin=True) decorator handles this via bot._dispatch_command, @@ -313,7 +307,6 @@ class TestCmdCronAdd: class TestCmdCronDel: def test_del_success(self): - _clear() bot = _FakeBot() async def inner(): @@ -327,25 +320,22 @@ class TestCmdCronDel: assert "Removed" in bot.replied[0] assert cron_id in bot.replied[0] assert len(bot.state.keys("cron")) == 0 - _clear() + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) def test_del_nonexistent(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron del nosuch"))) assert "No cron job" in bot.replied[0] def test_del_missing_id(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron del"))) assert "Usage:" in bot.replied[0] def test_del_with_hash_prefix(self): - _clear() bot = _FakeBot() async def inner(): @@ -356,7 +346,7 @@ class TestCmdCronDel: bot.replied.clear() await cmd_cron(bot, _msg(f"!cron del #{cron_id}")) assert "Removed" in bot.replied[0] - _clear() + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) @@ -368,13 +358,11 @@ class TestCmdCronDel: class TestCmdCronList: def test_list_empty(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron list"))) assert "No cron jobs" in bot.replied[0] def test_list_populated(self): - _clear() bot = _FakeBot() _save(bot, "#test:abc123", { "id": "abc123", "channel": "#test", @@ -386,13 +374,11 @@ class TestCmdCronList: assert "!rss check news" in bot.replied[0] def test_list_requires_channel(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _pm("!cron list"))) assert "Use this command in a channel" in bot.replied[0] def test_list_channel_isolation(self): - _clear() bot = _FakeBot() _save(bot, "#test:mine", { "id": "mine", "channel": "#test", @@ -413,13 +399,11 @@ class TestCmdCronList: class TestCmdCronUsage: def test_no_args(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron"))) assert "Usage:" in bot.replied[0] def test_unknown_subcommand(self): - _clear() bot = _FakeBot() asyncio.run(cmd_cron(bot, _msg("!cron foobar"))) assert "Usage:" in bot.replied[0] @@ -431,7 +415,6 @@ class TestCmdCronUsage: class TestRestore: def test_restore_spawns_tasks(self): - _clear() bot = _FakeBot() data = { "id": "abc123", "channel": "#test", @@ -443,15 +426,15 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:abc123" in _tasks - assert not _tasks["#test:abc123"].done() - _clear() + ps = _ps(bot) + assert "#test:abc123" in ps["tasks"] + assert not ps["tasks"]["#test:abc123"].done() + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_active(self): - _clear() bot = _FakeBot() data = { "id": "active", "channel": "#test", @@ -462,17 +445,17 @@ class TestRestore: _save(bot, "#test:active", data) async def inner(): + ps = _ps(bot) dummy = asyncio.create_task(asyncio.sleep(9999)) - _tasks["#test:active"] = dummy + ps["tasks"]["#test:active"] = dummy _restore(bot) - assert _tasks["#test:active"] is dummy + assert ps["tasks"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_restore_replaces_done_task(self): - _clear() bot = _FakeBot() data = { "id": "done", "channel": "#test", @@ -483,31 +466,30 @@ class TestRestore: _save(bot, "#test:done", data) async def inner(): + ps = _ps(bot) done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _tasks["#test:done"] = done_task + ps["tasks"]["#test:done"] = done_task _restore(bot) - new_task = _tasks["#test:done"] + new_task = ps["tasks"]["#test:done"] assert new_task is not done_task assert not new_task.done() - _clear() + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_bad_json(self): - _clear() bot = _FakeBot() bot.state.set("cron", "#test:bad", "not json{{{") async def inner(): _restore(bot) - assert "#test:bad" not in _tasks + assert "#test:bad" not in _ps(bot)["tasks"] asyncio.run(inner()) def test_on_connect_calls_restore(self): - _clear() bot = _FakeBot() data = { "id": "conn", "channel": "#test", @@ -520,8 +502,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:conn" in _tasks - _clear() + assert "#test:conn" in _ps(bot)["tasks"] + _clear(bot) await asyncio.sleep(0) asyncio.run(inner()) @@ -534,7 +516,6 @@ class TestRestore: class TestCronLoop: def test_dispatches_command(self): """Cron loop dispatches a synthetic message after interval.""" - _clear() bot = _FakeBot() async def inner(): @@ -545,10 +526,10 @@ class TestCronLoop: "added_by": "admin", } key = "#test:loop1" - _jobs[key] = data + _ps(bot)["jobs"][key] = data _start_job(bot, key) await asyncio.sleep(0.15) - _stop_job(key) + _stop_job(bot, key) await asyncio.sleep(0) # Should have dispatched at least once assert len(bot.dispatched) >= 1 @@ -560,8 +541,7 @@ class TestCronLoop: asyncio.run(inner()) def test_loop_stops_on_job_removal(self): - """Cron loop exits when job is removed from _jobs.""" - _clear() + """Cron loop exits when job is removed from jobs dict.""" bot = _FakeBot() async def inner(): @@ -572,12 +552,13 @@ class TestCronLoop: "added_by": "admin", } key = "#test:loop2" - _jobs[key] = data + ps = _ps(bot) + ps["jobs"][key] = data _start_job(bot, key) await asyncio.sleep(0.02) - _jobs.pop(key, None) + ps["jobs"].pop(key, None) await asyncio.sleep(0.1) - task = _tasks.get(key) + task = ps["tasks"].get(key) if task: assert task.done() @@ -590,7 +571,6 @@ class TestCronLoop: class TestJobManagement: def test_start_and_stop(self): - _clear() bot = _FakeBot() data = { "id": "mgmt", "channel": "#test", @@ -599,21 +579,21 @@ class TestJobManagement: "added_by": "admin", } key = "#test:mgmt" - _jobs[key] = data + ps = _ps(bot) + ps["jobs"][key] = data async def inner(): _start_job(bot, key) - assert key in _tasks - assert not _tasks[key].done() - _stop_job(key) + assert key in ps["tasks"] + assert not ps["tasks"][key].done() + _stop_job(bot, key) await asyncio.sleep(0) - assert key not in _tasks - assert key not in _jobs + assert key not in ps["tasks"] + assert key not in ps["jobs"] asyncio.run(inner()) def test_start_idempotent(self): - _clear() bot = _FakeBot() data = { "id": "idem", "channel": "#test", @@ -622,18 +602,19 @@ class TestJobManagement: "added_by": "admin", } key = "#test:idem" - _jobs[key] = data + ps = _ps(bot) + ps["jobs"][key] = data async def inner(): _start_job(bot, key) - first = _tasks[key] + first = ps["tasks"][key] _start_job(bot, key) - assert _tasks[key] is first - _stop_job(key) + assert ps["tasks"][key] is first + _stop_job(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): - _clear() - _stop_job("#test:nonexistent") + bot = _FakeBot() + _stop_job(bot, "#test:nonexistent") diff --git a/tests/test_integration.py b/tests/test_integration.py index 76dc287..edc9380 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -95,7 +95,7 @@ class _Harness: config["channels"] = channel_config self.registry = PluginRegistry() - self.bot = Bot(config, self.registry) + self.bot = Bot("test", config, self.registry) self.conn = _MockConnection() self.bot.conn = self.conn # type: ignore[assignment] self.bot.state = StateStore(":memory:") diff --git a/tests/test_paste_overflow.py b/tests/test_paste_overflow.py index b24617b..dd28197 100644 --- a/tests/test_paste_overflow.py +++ b/tests/test_paste_overflow.py @@ -29,7 +29,7 @@ def _make_bot(*, paste_threshold: int = 4, flaskpaste_mod=None) -> Bot: registry = PluginRegistry() if flaskpaste_mod is not None: registry._modules["flaskpaste"] = flaskpaste_mod - bot = Bot(config, registry) + bot = Bot("test", config, registry) bot._sent: list[tuple[str, str]] = [] # type: ignore[attr-defined] async def _capturing_send(target: str, text: str) -> None: diff --git a/tests/test_pastemoni.py b/tests/test_pastemoni.py index 1cc79a0..fd0c699 100644 --- a/tests/test_pastemoni.py +++ b/tests/test_pastemoni.py @@ -22,13 +22,11 @@ from plugins.pastemoni import ( # noqa: E402 _MAX_SEEN, _ArchiveParser, _delete, - _errors, _fetch_gists, _fetch_pastebin, _load, - _monitors, _poll_once, - _pollers, + _ps, _restore, _save, _snippet_around, @@ -132,6 +130,7 @@ class _FakeBot: self.replied: list[str] = [] self.state = _FakeState() self.registry = _FakeRegistry() + self._pstate: dict = {} self._admin = admin async def send(self, target: str, text: str) -> None: @@ -163,14 +162,17 @@ def _pm(text: str, nick: str = "alice") -> Message: ) -def _clear() -> None: - """Reset module-level state between tests.""" - for task in _pollers.values(): +def _clear(bot=None) -> None: + """Reset per-bot plugin state between tests.""" + if bot is None: + return + ps = _ps(bot) + for task in ps["pollers"].values(): if task and not task.done(): task.cancel() - _pollers.clear() - _monitors.clear() - _errors.clear() + ps["pollers"].clear() + ps["monitors"].clear() + ps["errors"].clear() def _fake_pb(keyword): @@ -428,7 +430,6 @@ class TestStateHelpers: class TestPollOnce: def test_new_items_announced(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "poll", "channel": "#test", @@ -437,7 +438,7 @@ class TestPollOnce: } key = "#test:poll" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -451,7 +452,6 @@ class TestPollOnce: asyncio.run(inner()) def test_seen_items_deduped(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "dedup", "channel": "#test", @@ -461,7 +461,7 @@ class TestPollOnce: } key = "#test:dedup" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -472,7 +472,6 @@ class TestPollOnce: def test_error_increments_counter(self): """All backends failing increments the error counter.""" - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "errs", "channel": "#test", @@ -481,20 +480,19 @@ class TestPollOnce: } key = "#test:errs" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data all_fail = {"pb": _fake_pb_error, "gh": _fake_gh_error} async def inner(): with patch.object(_mod, "_BACKENDS", all_fail): await _poll_once(bot, key, announce=True) - assert _errors[key] == 1 + assert _ps(bot)["errors"][key] == 1 assert len(bot.sent) == 0 asyncio.run(inner()) def test_partial_failure_resets_counter(self): """One backend succeeding resets the error counter.""" - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "partial", "channel": "#test", @@ -503,14 +501,14 @@ class TestPollOnce: } key = "#test:partial" _save(bot, key, data) - _monitors[key] = data - _errors[key] = 3 + _ps(bot)["monitors"][key] = data + _ps(bot)["errors"][key] = 3 partial_fail = {"pb": _fake_pb_error, "gh": _fake_gh} async def inner(): with patch.object(_mod, "_BACKENDS", partial_fail): await _poll_once(bot, key, announce=True) - assert _errors[key] == 0 + assert _ps(bot)["errors"][key] == 0 gh_msgs = [s for t, s in bot.sent if t == "#test" and "[gh]" in s] assert len(gh_msgs) == 1 @@ -518,7 +516,6 @@ class TestPollOnce: def test_max_announce_cap(self): """Only MAX_ANNOUNCE items announced per backend.""" - _clear() bot = _FakeBot() def _fake_many(keyword): @@ -535,7 +532,7 @@ class TestPollOnce: } key = "#test:cap" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}): @@ -548,7 +545,6 @@ class TestPollOnce: asyncio.run(inner()) def test_no_announce_flag(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "quiet", "channel": "#test", @@ -557,7 +553,7 @@ class TestPollOnce: } key = "#test:quiet" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): @@ -571,7 +567,6 @@ class TestPollOnce: def test_seen_cap(self): """Seen list capped at MAX_SEEN per backend.""" - _clear() bot = _FakeBot() def _fake_many(keyword): @@ -587,7 +582,7 @@ class TestPollOnce: } key = "#test:seencap" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): with patch.object(_mod, "_BACKENDS", {"pb": _fake_many}): @@ -605,7 +600,6 @@ class TestPollOnce: class TestCmdAdd: def test_add_success(self): - _clear() bot = _FakeBot(admin=True) async def inner(): @@ -621,38 +615,33 @@ class TestCmdAdd: assert data["channel"] == "#test" assert len(data["seen"]["pb"]) == 2 assert len(data["seen"]["gh"]) == 1 - assert "#test:leak-watch" in _pollers - _stop_poller("#test:leak-watch") + assert "#test:leak-watch" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:leak-watch") await asyncio.sleep(0) asyncio.run(inner()) def test_add_requires_admin(self): - _clear() bot = _FakeBot(admin=False) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add test keyword"))) assert "Permission denied" in bot.replied[0] def test_add_requires_channel(self): - _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni add test keyword"))) assert "Use this command in a channel" in bot.replied[0] def test_add_invalid_name(self): - _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add BAD! keyword"))) assert "Invalid name" in bot.replied[0] def test_add_missing_keyword(self): - _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni add myname"))) assert "Usage:" in bot.replied[0] def test_add_duplicate(self): - _clear() bot = _FakeBot(admin=True) async def inner(): @@ -663,13 +652,12 @@ class TestCmdAdd: with patch.object(_mod, "_BACKENDS", _FAKE_BACKENDS): await cmd_pastemoni(bot, _msg("!pastemoni add dupe other")) assert "already exists" in bot.replied[0] - _stop_poller("#test:dupe") + _stop_poller(bot, "#test:dupe") await asyncio.sleep(0) asyncio.run(inner()) def test_add_limit(self): - _clear() bot = _FakeBot(admin=True) for i in range(20): _save(bot, f"#test:mon{i}", {"name": f"mon{i}", "channel": "#test"}) @@ -684,7 +672,6 @@ class TestCmdAdd: class TestCmdDel: def test_del_success(self): - _clear() bot = _FakeBot(admin=True) async def inner(): @@ -695,25 +682,22 @@ class TestCmdDel: await cmd_pastemoni(bot, _msg("!pastemoni del todel")) assert "Removed 'todel'" in bot.replied[0] assert _load(bot, "#test:todel") is None - assert "#test:todel" not in _pollers + assert "#test:todel" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) def test_del_requires_admin(self): - _clear() bot = _FakeBot(admin=False) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del test"))) assert "Permission denied" in bot.replied[0] def test_del_nonexistent(self): - _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del nosuch"))) assert "No monitor" in bot.replied[0] def test_del_no_name(self): - _clear() bot = _FakeBot(admin=True) asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni del"))) assert "Usage:" in bot.replied[0] @@ -721,13 +705,11 @@ class TestCmdDel: class TestCmdList: def test_list_empty(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni list"))) assert "No monitors" in bot.replied[0] def test_list_populated(self): - _clear() bot = _FakeBot() _save(bot, "#test:leaks", { "name": "leaks", "channel": "#test", "keyword": "api_key", @@ -743,7 +725,6 @@ class TestCmdList: assert "creds" in bot.replied[0] def test_list_shows_errors(self): - _clear() bot = _FakeBot() _save(bot, "#test:broken", { "name": "broken", "channel": "#test", "keyword": "test", @@ -754,13 +735,11 @@ class TestCmdList: assert "1 errors" in bot.replied[0] def test_list_requires_channel(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni list"))) assert "Use this command in a channel" in bot.replied[0] def test_list_channel_isolation(self): - _clear() bot = _FakeBot() _save(bot, "#test:mine", { "name": "mine", "channel": "#test", "keyword": "test", @@ -777,7 +756,6 @@ class TestCmdList: class TestCmdCheck: def test_check_success(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "chk", "channel": "#test", @@ -794,19 +772,16 @@ class TestCmdCheck: asyncio.run(inner()) def test_check_nonexistent(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check nope"))) assert "No monitor" in bot.replied[0] def test_check_requires_channel(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _pm("!pastemoni check test"))) assert "Use this command in a channel" in bot.replied[0] def test_check_shows_error(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "errchk", "channel": "#test", @@ -824,7 +799,6 @@ class TestCmdCheck: asyncio.run(inner()) def test_check_announces_new_items(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "news", "channel": "#test", @@ -845,7 +819,6 @@ class TestCmdCheck: asyncio.run(inner()) def test_check_no_name(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni check"))) assert "Usage:" in bot.replied[0] @@ -853,13 +826,11 @@ class TestCmdCheck: class TestCmdUsage: def test_no_args(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni"))) assert "Usage:" in bot.replied[0] def test_unknown_subcommand(self): - _clear() bot = _FakeBot() asyncio.run(cmd_pastemoni(bot, _msg("!pastemoni foobar"))) assert "Usage:" in bot.replied[0] @@ -871,7 +842,6 @@ class TestCmdUsage: class TestRestore: def test_pollers_rebuilt_from_state(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "restored", "channel": "#test", @@ -882,15 +852,14 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:restored" in _pollers - assert not _pollers["#test:restored"].done() - _stop_poller("#test:restored") + assert "#test:restored" in _ps(bot)["pollers"] + assert not _ps(bot)["pollers"]["#test:restored"].done() + _stop_poller(bot, "#test:restored") await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_active(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "active", "channel": "#test", @@ -901,16 +870,15 @@ class TestRestore: async def inner(): dummy = asyncio.create_task(asyncio.sleep(9999)) - _pollers["#test:active"] = dummy + _ps(bot)["pollers"]["#test:active"] = dummy _restore(bot) - assert _pollers["#test:active"] is dummy + assert _ps(bot)["pollers"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_restore_replaces_done_task(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "done", "channel": "#test", @@ -922,29 +890,27 @@ class TestRestore: async def inner(): done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _pollers["#test:done"] = done_task + _ps(bot)["pollers"]["#test:done"] = done_task _restore(bot) - new_task = _pollers["#test:done"] + new_task = _ps(bot)["pollers"]["#test:done"] assert new_task is not done_task assert not new_task.done() - _stop_poller("#test:done") + _stop_poller(bot, "#test:done") await asyncio.sleep(0) asyncio.run(inner()) def test_restore_skips_bad_json(self): - _clear() bot = _FakeBot() bot.state.set("pastemoni", "#test:bad", "not json{{{") async def inner(): _restore(bot) - assert "#test:bad" not in _pollers + assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) def test_on_connect_calls_restore(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "conn", "channel": "#test", @@ -956,8 +922,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:conn" in _pollers - _stop_poller("#test:conn") + assert "#test:conn" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:conn") await asyncio.sleep(0) asyncio.run(inner()) @@ -969,7 +935,6 @@ class TestRestore: class TestPollerManagement: def test_start_and_stop(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "mgmt", "channel": "#test", @@ -978,21 +943,20 @@ class TestPollerManagement: } key = "#test:mgmt" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): _start_poller(bot, key) - assert key in _pollers - assert not _pollers[key].done() - _stop_poller(key) + assert key in _ps(bot)["pollers"] + assert not _ps(bot)["pollers"][key].done() + _stop_poller(bot, key) await asyncio.sleep(0) - assert key not in _pollers - assert key not in _monitors + assert key not in _ps(bot)["pollers"] + assert key not in _ps(bot)["monitors"] asyncio.run(inner()) def test_start_idempotent(self): - _clear() bot = _FakeBot() data = { "keyword": "test", "name": "idem", "channel": "#test", @@ -1001,18 +965,18 @@ class TestPollerManagement: } key = "#test:idem" _save(bot, key, data) - _monitors[key] = data + _ps(bot)["monitors"][key] = data async def inner(): _start_poller(bot, key) - first = _pollers[key] + first = _ps(bot)["pollers"][key] _start_poller(bot, key) - assert _pollers[key] is first - _stop_poller(key) + assert _ps(bot)["pollers"][key] is first + _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): - _clear() - _stop_poller("#test:nonexistent") + bot = _FakeBot() + _stop_poller(bot, "#test:nonexistent") diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 60de0ae..b8e6ad0 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -384,7 +384,7 @@ class TestPrefixMatch: async def _noop(bot, msg): pass registry.register_command(name, _noop, plugin="test") - return Bot(config, registry) + return Bot("test", config, registry) def test_exact_match(self): bot = self._make_bot(["ping", "pong", "plugins"]) @@ -438,7 +438,7 @@ class TestIsAdmin: "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins", "admins": admins or []}, } - bot = Bot(config, PluginRegistry()) + bot = Bot("test", config, PluginRegistry()) if opers: bot._opers = opers return bot @@ -565,7 +565,7 @@ def _make_test_bot() -> Bot: "nick": "test", "user": "test", "realname": "test"}, "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, } - bot = Bot(config, PluginRegistry()) + bot = Bot("test", config, PluginRegistry()) bot.conn = _FakeConnection() # type: ignore[assignment] return bot @@ -637,7 +637,7 @@ class TestChannelFilter: "bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"}, "channels": channels_cfg or {}, } - return Bot(config, PluginRegistry()) + return Bot("test", config, PluginRegistry()) def test_core_always_allowed(self): bot = self._make_bot({"#locked": {"plugins": ["core"]}}) diff --git a/tests/test_remind.py b/tests/test_remind.py index f4bb165..afd27f2 100644 --- a/tests/test_remind.py +++ b/tests/test_remind.py @@ -19,8 +19,6 @@ sys.modules[_spec.name] = _mod _spec.loader.exec_module(_mod) from plugins.remind import ( # noqa: E402 - _by_user, - _calendar, _cleanup, _delete_saved, _format_duration, @@ -30,9 +28,9 @@ from plugins.remind import ( # noqa: E402 _parse_date, _parse_duration, _parse_time, + _ps, _remind_once, _remind_repeat, - _reminders, _restore, _save, _schedule_at, @@ -74,6 +72,7 @@ class _FakeBot: self.replied: list[str] = [] self.config: dict = {"bot": {"timezone": tz}} self.state = _FakeState() + self._pstate: dict = {} async def send(self, target: str, text: str) -> None: self.sent.append((target, text)) @@ -98,15 +97,18 @@ def _pm(text: str, nick: str = "alice") -> Message: ) -def _clear() -> None: - """Reset global module state between tests.""" - for entry in _reminders.values(): +def _clear(bot=None) -> None: + """Reset per-bot plugin state between tests.""" + if bot is None: + return + ps = _ps(bot) + for entry in ps["reminders"].values(): task = entry[0] if task is not None and not task.done(): task.cancel() - _reminders.clear() - _by_user.clear() - _calendar.clear() + ps["reminders"].clear() + ps["by_user"].clear() + ps["calendar"].clear() async def _run_cmd(bot, msg): @@ -120,7 +122,7 @@ async def _run_cmd_and_cleanup(bot, msg): """Run cmd_remind, yield, then cancel all spawned tasks.""" await cmd_remind(bot, msg) await asyncio.sleep(0) - for entry in list(_reminders.values()): + for entry in list(_ps(bot)["reminders"].values()): if entry[0] is not None and not entry[0].done(): entry[0].cancel() await asyncio.sleep(0) @@ -229,47 +231,51 @@ class TestMakeId: class TestCleanup: def test_removes_from_both_structures(self): - _clear() - _reminders["abc123"] = (None, "#ch", "alice", "label", "12:00", False) - _by_user[("#ch", "alice")] = ["abc123"] + bot = _FakeBot() + ps = _ps(bot) + ps["reminders"]["abc123"] = (None, "#ch", "alice", "label", "12:00", False) + ps["by_user"][("#ch", "alice")] = ["abc123"] - _cleanup("abc123", "#ch", "alice") + _cleanup(bot, "abc123", "#ch", "alice") - assert "abc123" not in _reminders - assert ("#ch", "alice") not in _by_user + assert "abc123" not in ps["reminders"] + assert ("#ch", "alice") not in ps["by_user"] def test_removes_single_entry_from_multi(self): - _clear() - _reminders["aaa"] = (None, "#ch", "alice", "", "12:00", False) - _reminders["bbb"] = (None, "#ch", "alice", "", "12:00", False) - _by_user[("#ch", "alice")] = ["aaa", "bbb"] + bot = _FakeBot() + ps = _ps(bot) + ps["reminders"]["aaa"] = (None, "#ch", "alice", "", "12:00", False) + ps["reminders"]["bbb"] = (None, "#ch", "alice", "", "12:00", False) + ps["by_user"][("#ch", "alice")] = ["aaa", "bbb"] - _cleanup("aaa", "#ch", "alice") + _cleanup(bot, "aaa", "#ch", "alice") - assert "aaa" not in _reminders - assert _by_user[("#ch", "alice")] == ["bbb"] + assert "aaa" not in ps["reminders"] + assert ps["by_user"][("#ch", "alice")] == ["bbb"] def test_missing_rid_no_error(self): - _clear() - _cleanup("nonexistent", "#ch", "alice") + bot = _FakeBot() + _cleanup(bot, "nonexistent", "#ch", "alice") def test_missing_user_key_no_error(self): - _clear() - _reminders["abc"] = (None, "#ch", "alice", "", "12:00", False) + bot = _FakeBot() + ps = _ps(bot) + ps["reminders"]["abc"] = (None, "#ch", "alice", "", "12:00", False) - _cleanup("abc", "#ch", "bob") # different nick, user key absent + _cleanup(bot, "abc", "#ch", "bob") # different nick, user key absent - assert "abc" not in _reminders + assert "abc" not in ps["reminders"] def test_clears_calendar_set(self): - _clear() - _reminders["cal01"] = (None, "#ch", "alice", "", "12:00", False) - _by_user[("#ch", "alice")] = ["cal01"] - _calendar.add("cal01") + bot = _FakeBot() + ps = _ps(bot) + ps["reminders"]["cal01"] = (None, "#ch", "alice", "", "12:00", False) + ps["by_user"][("#ch", "alice")] = ["cal01"] + ps["calendar"].add("cal01") - _cleanup("cal01", "#ch", "alice") + _cleanup(bot, "cal01", "#ch", "alice") - assert "cal01" not in _calendar + assert "cal01" not in ps["calendar"] # --------------------------------------------------------------------------- @@ -278,16 +284,16 @@ class TestCleanup: class TestRemindOnce: def test_fires_metadata_and_label(self): - _clear() bot = _FakeBot() + ps = _ps(bot) async def inner(): rid = "once01" task = asyncio.create_task( _remind_once(bot, rid, "#ch", "alice", "check oven", 0, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False) - _by_user[("#ch", "alice")] = [rid] + ps["reminders"][rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False) + ps["by_user"][("#ch", "alice")] = [rid] await task asyncio.run(inner()) @@ -295,42 +301,42 @@ class TestRemindOnce: assert "alice: reminder #once01" in bot.sent[0][1] assert "12:00:00 UTC" in bot.sent[0][1] assert bot.sent[1] == ("#ch", "check oven") - assert "once01" not in _reminders + assert "once01" not in _ps(bot)["reminders"] def test_empty_label_sends_one_line(self): - _clear() bot = _FakeBot() + ps = _ps(bot) async def inner(): rid = "once02" task = asyncio.create_task( _remind_once(bot, rid, "#ch", "bob", "", 0, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False) - _by_user[("#ch", "bob")] = [rid] + ps["reminders"][rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False) + ps["by_user"][("#ch", "bob")] = [rid] await task asyncio.run(inner()) assert len(bot.sent) == 1 def test_cancellation_cleans_up(self): - _clear() bot = _FakeBot() + ps = _ps(bot) async def inner(): rid = "once03" task = asyncio.create_task( _remind_once(bot, rid, "#ch", "alice", "text", 9999, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False) - _by_user[("#ch", "alice")] = [rid] + ps["reminders"][rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False) + ps["by_user"][("#ch", "alice")] = [rid] await asyncio.sleep(0) task.cancel() await asyncio.gather(task, return_exceptions=True) asyncio.run(inner()) assert len(bot.sent) == 0 - assert "once03" not in _reminders + assert "once03" not in _ps(bot)["reminders"] # --------------------------------------------------------------------------- @@ -339,16 +345,16 @@ class TestRemindOnce: class TestRemindRepeat: def test_fires_at_least_once(self): - _clear() bot = _FakeBot() + ps = _ps(bot) async def inner(): rid = "rpt01" task = asyncio.create_task( _remind_repeat(bot, rid, "#ch", "alice", "hydrate", 0, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True) - _by_user[("#ch", "alice")] = [rid] + ps["reminders"][rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True) + ps["by_user"][("#ch", "alice")] = [rid] for _ in range(5): await asyncio.sleep(0) task.cancel() @@ -357,26 +363,26 @@ class TestRemindRepeat: asyncio.run(inner()) assert len(bot.sent) >= 2 # at least one fire (metadata + label) assert any("rpt01" in t for _, t in bot.sent) - assert "rpt01" not in _reminders + assert "rpt01" not in _ps(bot)["reminders"] def test_cancellation_cleans_up(self): - _clear() bot = _FakeBot() + ps = _ps(bot) async def inner(): rid = "rpt02" task = asyncio.create_task( _remind_repeat(bot, rid, "#ch", "bob", "stretch", 9999, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True) - _by_user[("#ch", "bob")] = [rid] + ps["reminders"][rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True) + ps["by_user"][("#ch", "bob")] = [rid] await asyncio.sleep(0) task.cancel() await asyncio.gather(task, return_exceptions=True) asyncio.run(inner()) assert len(bot.sent) == 0 - assert "rpt02" not in _reminders + assert "rpt02" not in _ps(bot)["reminders"] # --------------------------------------------------------------------------- @@ -385,25 +391,21 @@ class TestRemindRepeat: class TestCmdRemindUsage: def test_no_args_shows_usage(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind"))) assert "Usage:" in bot.replied[0] def test_invalid_duration(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind xyz some text"))) assert "Invalid duration" in bot.replied[0] def test_every_no_args(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind every"))) assert "Invalid duration" in bot.replied[0] def test_every_invalid_duration(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind every abc"))) assert "Invalid duration" in bot.replied[0] @@ -415,7 +417,6 @@ class TestCmdRemindUsage: class TestCmdRemindOneshot: def test_creates_with_duration(self): - _clear() bot = _FakeBot() async def inner(): @@ -427,7 +428,6 @@ class TestCmdRemindOneshot: assert "#" in bot.replied[0] def test_no_label(self): - _clear() bot = _FakeBot() async def inner(): @@ -437,27 +437,26 @@ class TestCmdRemindOneshot: assert "set (5m)" in bot.replied[0] def test_stores_in_tracking(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _msg("!remind 9999s task")) - assert len(_reminders) == 1 - entry = next(iter(_reminders.values())) + ps = _ps(bot) + assert len(ps["reminders"]) == 1 + entry = next(iter(ps["reminders"].values())) assert entry[1] == "#test" # target assert entry[2] == "alice" # nick assert entry[3] == "task" # label assert entry[5] is False # not repeating - assert ("#test", "alice") in _by_user + assert ("#test", "alice") in ps["by_user"] # cleanup - for e in _reminders.values(): + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_days_duration(self): - _clear() bot = _FakeBot() async def inner(): @@ -473,7 +472,6 @@ class TestCmdRemindOneshot: class TestCmdRemindRepeat: def test_creates_repeating(self): - _clear() bot = _FakeBot() async def inner(): @@ -484,21 +482,20 @@ class TestCmdRemindRepeat: assert "every 1h" in bot.replied[0] def test_repeating_stores_flag(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _msg("!remind every 30m stretch")) - entry = next(iter(_reminders.values())) + ps = _ps(bot) + entry = next(iter(ps["reminders"].values())) assert entry[5] is True # repeating flag - for e in _reminders.values(): + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_repeating_no_label(self): - _clear() bot = _FakeBot() async def inner(): @@ -514,20 +511,18 @@ class TestCmdRemindRepeat: class TestCmdRemindList: def test_empty_list(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind list"))) assert "No active reminders" in bot.replied[0] def test_shows_active(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _msg("!remind 9999s task")) bot.replied.clear() await cmd_remind(bot, _msg("!remind list")) - for e in _reminders.values(): + for e in _ps(bot)["reminders"].values(): e[0].cancel() await asyncio.sleep(0) @@ -536,14 +531,13 @@ class TestCmdRemindList: assert "#" in bot.replied[0] def test_shows_repeat_tag(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _msg("!remind every 9999s task")) bot.replied.clear() await cmd_remind(bot, _msg("!remind list")) - for e in _reminders.values(): + for e in _ps(bot)["reminders"].values(): e[0].cancel() await asyncio.sleep(0) @@ -561,7 +555,6 @@ class TestCmdRemindCancel: return reply.split("#")[1].split(" ")[0] def test_cancel_valid(self): - _clear() bot = _FakeBot() async def inner(): @@ -575,7 +568,6 @@ class TestCmdRemindCancel: assert "Cancelled" in bot.replied[0] def test_cancel_with_hash_prefix(self): - _clear() bot = _FakeBot() async def inner(): @@ -589,7 +581,6 @@ class TestCmdRemindCancel: assert "Cancelled" in bot.replied[0] def test_cancel_wrong_user(self): - _clear() bot = _FakeBot() async def inner(): @@ -597,7 +588,7 @@ class TestCmdRemindCancel: rid = self._extract_rid(bot.replied[0]) bot.replied.clear() await cmd_remind(bot, _msg(f"!remind cancel {rid}", nick="eve")) - for e in _reminders.values(): + for e in _ps(bot)["reminders"].values(): e[0].cancel() await asyncio.sleep(0) @@ -605,13 +596,11 @@ class TestCmdRemindCancel: assert "No active reminder" in bot.replied[0] def test_cancel_nonexistent(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind cancel ffffff"))) assert "No active reminder" in bot.replied[0] def test_cancel_no_id(self): - _clear() bot = _FakeBot() asyncio.run(cmd_remind(bot, _msg("!remind cancel"))) assert "Usage:" in bot.replied[0] @@ -623,28 +612,28 @@ class TestCmdRemindCancel: class TestCmdRemindTarget: def test_channel_target(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _msg("!remind 9999s task", target="#ops")) - entry = next(iter(_reminders.values())) + ps = _ps(bot) + entry = next(iter(ps["reminders"].values())) assert entry[1] == "#ops" - for e in _reminders.values(): + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_pm_uses_nick(self): - _clear() bot = _FakeBot() async def inner(): await _run_cmd(bot, _pm("!remind 9999s task")) - entry = next(iter(_reminders.values())) + ps = _ps(bot) + entry = next(iter(ps["reminders"].values())) assert entry[1] == "alice" # nick, not "botname" - for e in _reminders.values(): + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) @@ -788,7 +777,6 @@ class TestCmdRemindAt: return reply.split("#")[1].split(" ")[0] def test_valid_future_date(self): - _clear() bot = _FakeBot() future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") @@ -801,7 +789,6 @@ class TestCmdRemindAt: assert "deploy release" not in bot.replied[0] # label not in confirmation def test_past_date_rejected(self): - _clear() bot = _FakeBot() async def inner(): @@ -811,7 +798,6 @@ class TestCmdRemindAt: assert "past" in bot.replied[0].lower() def test_default_time_noon(self): - _clear() bot = _FakeBot() future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") @@ -822,7 +808,6 @@ class TestCmdRemindAt: assert "12:00" in bot.replied[0] def test_with_explicit_time(self): - _clear() bot = _FakeBot() future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") @@ -833,7 +818,6 @@ class TestCmdRemindAt: assert "14:30" in bot.replied[0] def test_stores_in_state(self): - _clear() bot = _FakeBot() future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") @@ -846,15 +830,15 @@ class TestCmdRemindAt: assert data["type"] == "at" assert data["nick"] == "alice" assert data["label"] == "persist me" - assert rid in _calendar - for e in _reminders.values(): + ps = _ps(bot) + assert rid in ps["calendar"] + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_invalid_date_format(self): - _clear() bot = _FakeBot() async def inner(): @@ -864,7 +848,6 @@ class TestCmdRemindAt: assert "Invalid date" in bot.replied[0] def test_no_args_shows_usage(self): - _clear() bot = _FakeBot() async def inner(): @@ -883,7 +866,6 @@ class TestCmdRemindYearly: return reply.split("#")[1].split(" ")[0] def test_valid_creation(self): - _clear() bot = _FakeBot() async def inner(): @@ -894,7 +876,6 @@ class TestCmdRemindYearly: assert "yearly 06-15" in bot.replied[0] def test_invalid_date(self): - _clear() bot = _FakeBot() async def inner(): @@ -904,7 +885,6 @@ class TestCmdRemindYearly: assert "Invalid date" in bot.replied[0] def test_invalid_day(self): - _clear() bot = _FakeBot() async def inner(): @@ -914,7 +894,6 @@ class TestCmdRemindYearly: assert "Invalid date" in bot.replied[0] def test_stores_in_state(self): - _clear() bot = _FakeBot() async def inner(): @@ -926,15 +905,15 @@ class TestCmdRemindYearly: assert data["type"] == "yearly" assert data["month_day"] == "02-14" assert data["nick"] == "alice" - assert rid in _calendar - for e in _reminders.values(): + ps = _ps(bot) + assert rid in ps["calendar"] + for e in ps["reminders"].values(): e[0].cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_with_explicit_time(self): - _clear() bot = _FakeBot() async def inner(): @@ -944,7 +923,6 @@ class TestCmdRemindYearly: assert "yearly 12-25" in bot.replied[0] def test_no_args_shows_usage(self): - _clear() bot = _FakeBot() async def inner(): @@ -954,7 +932,6 @@ class TestCmdRemindYearly: assert "Usage:" in bot.replied[0] def test_leap_day_allowed(self): - _clear() bot = _FakeBot() async def inner(): @@ -997,7 +974,6 @@ class TestCalendarPersistence: assert bot.state.get("remind", "abc123") is None def test_cancel_deletes_from_state(self): - _clear() bot = _FakeBot() future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d") @@ -1013,7 +989,6 @@ class TestCalendarPersistence: asyncio.run(inner()) def test_at_fire_deletes_from_state(self): - _clear() bot = _FakeBot() async def inner(): @@ -1026,12 +1001,13 @@ class TestCalendarPersistence: "created": "12:00:00 UTC", } _save(bot, rid, data) - _calendar.add(rid) + ps = _ps(bot) + ps["calendar"].add(rid) task = asyncio.create_task( _schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"), ) - _reminders[rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False) - _by_user[("#ch", "alice")] = [rid] + ps["reminders"][rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False) + ps["by_user"][("#ch", "alice")] = [rid] await task assert bot.state.get("remind", rid) is None @@ -1044,7 +1020,6 @@ class TestCalendarPersistence: class TestRestore: def test_restores_at_from_state(self): - _clear() bot = _FakeBot() fire_dt = datetime.now(timezone.utc) + timedelta(hours=1) data = { @@ -1057,9 +1032,10 @@ class TestRestore: async def inner(): _restore(bot) - assert "rest01" in _reminders - assert "rest01" in _calendar - entry = _reminders["rest01"] + ps = _ps(bot) + assert "rest01" in ps["reminders"] + assert "rest01" in ps["calendar"] + entry = ps["reminders"]["rest01"] assert not entry[0].done() entry[0].cancel() await asyncio.sleep(0) @@ -1067,7 +1043,6 @@ class TestRestore: asyncio.run(inner()) def test_restores_yearly_from_state(self): - _clear() bot = _FakeBot() fire_dt = datetime.now(timezone.utc) + timedelta(days=180) data = { @@ -1080,9 +1055,10 @@ class TestRestore: async def inner(): _restore(bot) - assert "rest02" in _reminders - assert "rest02" in _calendar - entry = _reminders["rest02"] + ps = _ps(bot) + assert "rest02" in ps["reminders"] + assert "rest02" in ps["calendar"] + entry = ps["reminders"]["rest02"] assert not entry[0].done() entry[0].cancel() await asyncio.sleep(0) @@ -1090,7 +1066,6 @@ class TestRestore: asyncio.run(inner()) def test_skips_active_rids(self): - _clear() bot = _FakeBot() fire_dt = datetime.now(timezone.utc) + timedelta(hours=1) data = { @@ -1103,18 +1078,18 @@ class TestRestore: async def inner(): # Pre-populate with an active task + ps = _ps(bot) dummy = asyncio.create_task(asyncio.sleep(9999)) - _reminders["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False) + ps["reminders"]["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False) _restore(bot) # Should still be the dummy task, not replaced - assert _reminders["skip01"][0] is dummy + assert ps["reminders"]["skip01"][0] is dummy dummy.cancel() await asyncio.sleep(0) asyncio.run(inner()) def test_past_at_cleaned_up(self): - _clear() bot = _FakeBot() past_dt = datetime.now(timezone.utc) - timedelta(hours=1) data = { @@ -1128,13 +1103,12 @@ class TestRestore: async def inner(): _restore(bot) # Past at-reminder should be deleted from state, not scheduled - assert "past01" not in _reminders + assert "past01" not in _ps(bot)["reminders"] assert bot.state.get("remind", "past01") is None asyncio.run(inner()) def test_past_yearly_recalculated(self): - _clear() bot = _FakeBot() past_dt = datetime.now(timezone.utc) - timedelta(days=30) data = { @@ -1147,13 +1121,14 @@ class TestRestore: async def inner(): _restore(bot) - assert "yearly01" in _reminders + ps = _ps(bot) + assert "yearly01" in ps["reminders"] # fire_iso should have been updated to a future date raw = bot.state.get("remind", "yearly01") updated = json.loads(raw) new_fire = datetime.fromisoformat(updated["fire_iso"]) assert new_fire > datetime.now(timezone.utc) - _reminders["yearly01"][0].cancel() + ps["reminders"]["yearly01"][0].cancel() await asyncio.sleep(0) asyncio.run(inner()) diff --git a/tests/test_rss.py b/tests/test_rss.py index 0715033..8e82c64 100644 --- a/tests/test_rss.py +++ b/tests/test_rss.py @@ -21,15 +21,13 @@ from plugins.rss import ( # noqa: E402 _MAX_SEEN, _delete, _derive_name, - _errors, - _feeds, _load, _parse_atom, _parse_date, _parse_feed, _parse_rss, _poll_once, - _pollers, + _ps, _restore, _save, _start_poller, @@ -158,6 +156,7 @@ class _FakeBot: self.sent: list[tuple[str, str]] = [] self.replied: list[str] = [] self.state = _FakeState() + self._pstate: dict = {} self._admin = admin async def send(self, target: str, text: str) -> None: @@ -190,13 +189,7 @@ def _pm(text: str, nick: str = "alice") -> Message: def _clear() -> None: - """Reset module-level state between tests.""" - for task in _pollers.values(): - if task and not task.done(): - task.cancel() - _pollers.clear() - _feeds.clear() - _errors.clear() + """No-op -- state is per-bot now, each _FakeBot starts fresh.""" def _fake_fetch_ok(url, etag="", last_modified=""): @@ -512,8 +505,8 @@ class TestCmdRssAdd: assert data["name"] == "testfeed" assert data["channel"] == "#test" assert len(data["seen"]) == 3 - assert "#test:testfeed" in _pollers - _stop_poller("#test:testfeed") + assert "#test:testfeed" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:testfeed") await asyncio.sleep(0) asyncio.run(inner()) @@ -527,7 +520,7 @@ class TestCmdRssAdd: await cmd_rss(bot, _msg("!rss add https://hnrss.org/newest")) await asyncio.sleep(0) assert "Subscribed 'hnrss'" in bot.replied[0] - _stop_poller("#test:hnrss") + _stop_poller(bot, "#test:hnrss") await asyncio.sleep(0) asyncio.run(inner()) @@ -562,7 +555,7 @@ class TestCmdRssAdd: with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): await cmd_rss(bot, _msg("!rss add https://other.com/feed myfeed")) assert "already exists" in bot.replied[0] - _stop_poller("#test:myfeed") + _stop_poller(bot, "#test:myfeed") await asyncio.sleep(0) asyncio.run(inner()) @@ -608,7 +601,7 @@ class TestCmdRssAdd: await asyncio.sleep(0) data = _load(bot, "#test:test") assert data["url"] == "https://example.com/feed" - _stop_poller("#test:test") + _stop_poller(bot, "#test:test") await asyncio.sleep(0) asyncio.run(inner()) @@ -631,7 +624,7 @@ class TestCmdRssDel: await cmd_rss(bot, _msg("!rss del delfeed")) assert "Unsubscribed 'delfeed'" in bot.replied[0] assert _load(bot, "#test:delfeed") is None - assert "#test:delfeed" not in _pollers + assert "#test:delfeed" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) @@ -819,7 +812,7 @@ class TestPollOnce: } key = "#test:f304" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_304): @@ -839,13 +832,13 @@ class TestPollOnce: } key = "#test:ferr" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_error): await _poll_once(bot, key) await _poll_once(bot, key) - assert _errors[key] == 2 + assert _ps(bot)["errors"][key] == 2 updated = _load(bot, key) assert updated["last_error"] == "Connection refused" @@ -880,7 +873,7 @@ class TestPollOnce: } key = "#test:big" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", fake_big): @@ -902,7 +895,7 @@ class TestPollOnce: } key = "#test:quiet" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): @@ -926,7 +919,7 @@ class TestPollOnce: } key = "#test:etag" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): @@ -954,10 +947,10 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:restored" in _pollers - task = _pollers["#test:restored"] + assert "#test:restored" in _ps(bot)["pollers"] + task = _ps(bot)["pollers"]["#test:restored"] assert not task.done() - _stop_poller("#test:restored") + _stop_poller(bot, "#test:restored") await asyncio.sleep(0) asyncio.run(inner()) @@ -975,10 +968,10 @@ class TestRestore: async def inner(): # Pre-place an active task dummy = asyncio.create_task(asyncio.sleep(9999)) - _pollers["#test:active"] = dummy + _ps(bot)["pollers"]["#test:active"] = dummy _restore(bot) # Should not have replaced it - assert _pollers["#test:active"] is dummy + assert _ps(bot)["pollers"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) @@ -998,13 +991,13 @@ class TestRestore: # Place a completed task done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _pollers["#test:done"] = done_task + _ps(bot)["pollers"]["#test:done"] = done_task _restore(bot) # Should have been replaced - new_task = _pollers["#test:done"] + new_task = _ps(bot)["pollers"]["#test:done"] assert new_task is not done_task assert not new_task.done() - _stop_poller("#test:done") + _stop_poller(bot, "#test:done") await asyncio.sleep(0) asyncio.run(inner()) @@ -1016,7 +1009,7 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:bad" not in _pollers + assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) @@ -1033,8 +1026,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:conn" in _pollers - _stop_poller("#test:conn") + assert "#test:conn" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:conn") await asyncio.sleep(0) asyncio.run(inner()) @@ -1055,16 +1048,17 @@ class TestPollerManagement: } key = "#test:mgmt" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): _start_poller(bot, key) - assert key in _pollers - assert not _pollers[key].done() - _stop_poller(key) + ps = _ps(bot) + assert key in ps["pollers"] + assert not ps["pollers"][key].done() + _stop_poller(bot, key) await asyncio.sleep(0) - assert key not in _pollers - assert key not in _feeds + assert key not in ps["pollers"] + assert key not in ps["feeds"] asyncio.run(inner()) @@ -1078,22 +1072,24 @@ class TestPollerManagement: } key = "#test:idem" _save(bot, key, data) - _feeds[key] = data + _ps(bot)["feeds"][key] = data async def inner(): _start_poller(bot, key) - first = _pollers[key] + ps = _ps(bot) + first = ps["pollers"][key] _start_poller(bot, key) - assert _pollers[key] is first - _stop_poller(key) + assert ps["pollers"][key] is first + _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): _clear() + bot = _FakeBot() # Should not raise - _stop_poller("#test:nonexistent") + _stop_poller(bot, "#test:nonexistent") # --------------------------------------------------------------------------- diff --git a/tests/test_twitch.py b/tests/test_twitch.py index 8d6a9ba..181f4ff 100644 --- a/tests/test_twitch.py +++ b/tests/test_twitch.py @@ -19,16 +19,14 @@ _spec.loader.exec_module(_mod) from plugins.twitch import ( # noqa: E402 _compact_num, _delete, - _errors, _load, _poll_once, - _pollers, + _ps, _restore, _save, _start_poller, _state_key, _stop_poller, - _streamers, _truncate, _validate_name, cmd_twitch, @@ -131,6 +129,7 @@ class _FakeBot: self.sent: list[tuple[str, str]] = [] self.replied: list[str] = [] self.state = _FakeState() + self._pstate: dict = {} self._admin = admin async def send(self, target: str, text: str) -> None: @@ -160,13 +159,7 @@ def _pm(text: str, nick: str = "alice") -> Message: def _clear() -> None: - """Reset module-level state between tests.""" - for task in _pollers.values(): - if task and not task.done(): - task.cancel() - _pollers.clear() - _streamers.clear() - _errors.clear() + """No-op -- state is per-bot now, each _FakeBot starts fresh.""" def _fake_query_live(login): @@ -439,8 +432,8 @@ class TestCmdTwitchFollow: assert data["name"] == "xqc" assert data["channel"] == "#test" assert data["was_live"] is False - assert "#test:xqc" in _pollers - _stop_poller("#test:xqc") + assert "#test:xqc" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:xqc") await asyncio.sleep(0) asyncio.run(inner()) @@ -457,7 +450,7 @@ class TestCmdTwitchFollow: data = _load(bot, "#test:my-streamer") assert data is not None assert data["name"] == "my-streamer" - _stop_poller("#test:my-streamer") + _stop_poller(bot, "#test:my-streamer") await asyncio.sleep(0) asyncio.run(inner()) @@ -476,7 +469,7 @@ class TestCmdTwitchFollow: assert data["stream_id"] == "12345" # Should NOT have announced (seed, not transition) assert len(bot.sent) == 0 - _stop_poller("#test:xqc") + _stop_poller(bot, "#test:xqc") await asyncio.sleep(0) asyncio.run(inner()) @@ -585,7 +578,7 @@ class TestCmdTwitchUnfollow: await cmd_twitch(bot, _msg("!twitch unfollow xqc")) assert "Unfollowed 'xqc'" in bot.replied[0] assert _load(bot, "#test:xqc") is None - assert "#test:xqc" not in _pollers + assert "#test:xqc" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) @@ -798,7 +791,7 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_live): @@ -828,7 +821,7 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_live): @@ -851,7 +844,7 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_live_new_stream): @@ -877,7 +870,7 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_offline): @@ -899,13 +892,13 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_error): await _poll_once(bot, key) await _poll_once(bot, key) - assert _errors[key] == 2 + assert _ps(bot)["errors"][key] == 2 updated = _load(bot, key) assert updated["last_error"] == "Connection refused" @@ -923,7 +916,7 @@ class TestPollOnce: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_live): @@ -947,7 +940,7 @@ class TestPollOnce: } key = "#test:streamer" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): with patch.object(_mod, "_query_stream", _fake_query_live_no_game): @@ -990,10 +983,10 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:xqc" in _pollers - task = _pollers["#test:xqc"] + assert "#test:xqc" in _ps(bot)["pollers"] + task = _ps(bot)["pollers"]["#test:xqc"] assert not task.done() - _stop_poller("#test:xqc") + _stop_poller(bot, "#test:xqc") await asyncio.sleep(0) asyncio.run(inner()) @@ -1011,9 +1004,9 @@ class TestRestore: async def inner(): dummy = asyncio.create_task(asyncio.sleep(9999)) - _pollers["#test:xqc"] = dummy + _ps(bot)["pollers"]["#test:xqc"] = dummy _restore(bot) - assert _pollers["#test:xqc"] is dummy + assert _ps(bot)["pollers"]["#test:xqc"] is dummy dummy.cancel() await asyncio.sleep(0) @@ -1033,12 +1026,12 @@ class TestRestore: async def inner(): done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _pollers["#test:xqc"] = done_task + _ps(bot)["pollers"]["#test:xqc"] = done_task _restore(bot) - new_task = _pollers["#test:xqc"] + new_task = _ps(bot)["pollers"]["#test:xqc"] assert new_task is not done_task assert not new_task.done() - _stop_poller("#test:xqc") + _stop_poller(bot, "#test:xqc") await asyncio.sleep(0) asyncio.run(inner()) @@ -1050,7 +1043,7 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:bad" not in _pollers + assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) @@ -1068,8 +1061,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:xqc" in _pollers - _stop_poller("#test:xqc") + assert "#test:xqc" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:xqc") await asyncio.sleep(0) asyncio.run(inner()) @@ -1091,16 +1084,17 @@ class TestPollerManagement: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): _start_poller(bot, key) - assert key in _pollers - assert not _pollers[key].done() - _stop_poller(key) + ps = _ps(bot) + assert key in ps["pollers"] + assert not ps["pollers"][key].done() + _stop_poller(bot, key) await asyncio.sleep(0) - assert key not in _pollers - assert key not in _streamers + assert key not in ps["pollers"] + assert key not in ps["streamers"] asyncio.run(inner()) @@ -1115,21 +1109,23 @@ class TestPollerManagement: } key = "#test:xqc" _save(bot, key, data) - _streamers[key] = data + _ps(bot)["streamers"][key] = data async def inner(): _start_poller(bot, key) - first = _pollers[key] + ps = _ps(bot) + first = ps["pollers"][key] _start_poller(bot, key) - assert _pollers[key] is first - _stop_poller(key) + assert ps["pollers"][key] is first + _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): _clear() - _stop_poller("#test:nonexistent") + bot = _FakeBot() + _stop_poller(bot, "#test:nonexistent") # --------------------------------------------------------------------------- diff --git a/tests/test_urltitle.py b/tests/test_urltitle.py index 1a0eaa9..da83f31 100644 --- a/tests/test_urltitle.py +++ b/tests/test_urltitle.py @@ -25,7 +25,7 @@ from plugins.urltitle import ( # noqa: E402, I001 _extract_urls, _fetch_title, _is_ignored_url, - _seen, + _ps, on_privmsg, ) @@ -40,6 +40,7 @@ class _FakeBot: self.sent: list[tuple[str, str]] = [] self.nick = "derp" self.prefix = "!" + self._pstate: dict = {} self.config = { "flaskpaste": {"url": "https://paste.mymx.me"}, "urltitle": {}, @@ -334,26 +335,28 @@ class TestFetchTitle: class TestCooldown: def setup_method(self): - _seen.clear() + self.bot = _FakeBot() def test_first_access_not_cooled(self): - assert _check_cooldown("https://a.com", 300) is False + assert _check_cooldown(self.bot, "https://a.com", 300) is False def test_second_access_within_window(self): - _check_cooldown("https://b.com", 300) - assert _check_cooldown("https://b.com", 300) is True + _check_cooldown(self.bot, "https://b.com", 300) + assert _check_cooldown(self.bot, "https://b.com", 300) is True def test_after_cooldown_expires(self): - _seen["https://c.com"] = time.monotonic() - 400 - assert _check_cooldown("https://c.com", 300) is False + seen = _ps(self.bot)["seen"] + seen["https://c.com"] = time.monotonic() - 400 + assert _check_cooldown(self.bot, "https://c.com", 300) is False def test_pruning(self): """Cache is pruned when it exceeds max size.""" + seen = _ps(self.bot)["seen"] old = time.monotonic() - 600 for i in range(600): - _seen[f"https://stale-{i}.com"] = old - _check_cooldown("https://new.com", 300) - assert len(_seen) < 600 + seen[f"https://stale-{i}.com"] = old + _check_cooldown(self.bot, "https://new.com", 300) + assert len(seen) < 600 # --------------------------------------------------------------------------- @@ -361,8 +364,6 @@ class TestCooldown: # --------------------------------------------------------------------------- class TestOnPrivmsg: - def setup_method(self): - _seen.clear() def test_channel_url_previewed(self): bot = _FakeBot() diff --git a/tests/test_webhook.py b/tests/test_webhook.py index fbf118a..93618fa 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -23,6 +23,7 @@ from plugins.webhook import ( # noqa: E402 _MAX_BODY, _handle_request, _http_response, + _ps, _verify_signature, cmd_webhook, on_connect, @@ -62,6 +63,7 @@ class _FakeBot: self.replied: list[str] = [] self.actions: list[tuple[str, str]] = [] self.state = _FakeState() + self._pstate: dict = {} self._admin = admin self.prefix = "!" self.config = { @@ -301,14 +303,14 @@ class TestRequestHandler: def test_counter_increments(self): bot = _FakeBot() - # Reset counter - _mod._request_count = 0 + ps = _ps(bot) + ps["request_count"] = 0 body = json.dumps({"channel": "#test", "text": "hi"}).encode() raw = _build_request("POST", body, {"Content-Length": str(len(body))}) reader = _FakeReader(raw) writer = _FakeWriter() asyncio.run(_handle_request(reader, writer, bot, "")) - assert _mod._request_count == 1 + assert ps["request_count"] == 1 # --------------------------------------------------------------------------- @@ -320,28 +322,23 @@ class TestServerLifecycle: def test_disabled_config(self): """Server does not start when webhook is disabled.""" bot = _FakeBot(webhook_cfg={"enabled": False}) - msg = _msg("", target="") msg = Message(raw="", prefix="", nick="", command="001", params=["test", "Welcome"], tags={}) - # Reset global state - _mod._server = None asyncio.run(on_connect(bot, msg)) - assert _mod._server is None + assert _ps(bot)["server"] is None def test_duplicate_guard(self): """Second on_connect does not create a second server.""" sentinel = object() - _mod._server = sentinel bot = _FakeBot(webhook_cfg={"enabled": True, "port": 0}) + _ps(bot)["server"] = sentinel msg = Message(raw="", prefix="", nick="", command="001", params=["test", "Welcome"], tags={}) asyncio.run(on_connect(bot, msg)) - assert _mod._server is sentinel - _mod._server = None # cleanup + assert _ps(bot)["server"] is sentinel def test_on_connect_starts(self): """on_connect starts the server when enabled.""" - _mod._server = None bot = _FakeBot(webhook_cfg={ "enabled": True, "host": "127.0.0.1", "port": 0, "secret": "", }) @@ -350,10 +347,10 @@ class TestServerLifecycle: async def _run(): await on_connect(bot, msg) - assert _mod._server is not None - _mod._server.close() - await _mod._server.wait_closed() - _mod._server = None + ps = _ps(bot) + assert ps["server"] is not None + ps["server"].close() + await ps["server"].wait_closed() asyncio.run(_run()) @@ -366,26 +363,25 @@ class TestServerLifecycle: class TestWebhookCommand: def test_not_running(self): bot = _FakeBot() - _mod._server = None asyncio.run(cmd_webhook(bot, _msg("!webhook"))) assert any("not running" in r for r in bot.replied) def test_running_shows_status(self): bot = _FakeBot() - _mod._request_count = 42 - _mod._started = time.monotonic() - 90 # 1m 30s ago + ps = _ps(bot) + ps["request_count"] = 42 + ps["started"] = time.monotonic() - 90 # 1m 30s ago async def _run(): # Start a real server on port 0 to get a valid socket srv = await asyncio.start_server(lambda r, w: None, "127.0.0.1", 0) - _mod._server = srv + ps["server"] = srv try: await cmd_webhook(bot, _msg("!webhook")) finally: srv.close() await srv.wait_closed() - _mod._server = None asyncio.run(_run()) assert len(bot.replied) == 1 diff --git a/tests/test_youtube.py b/tests/test_youtube.py index d2431c9..8dece15 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -18,18 +18,16 @@ _spec.loader.exec_module(_mod) from plugins.youtube import ( # noqa: E402 _MAX_ANNOUNCE, - _channels, _compact_num, _delete, _derive_name, - _errors, _extract_channel_id, _format_duration, _is_youtube_url, _load, _parse_feed, _poll_once, - _pollers, + _ps, _restore, _save, _start_poller, @@ -163,6 +161,7 @@ class _FakeBot: self.sent: list[tuple[str, str]] = [] self.replied: list[str] = [] self.state = _FakeState() + self._pstate: dict = {} self._admin = admin async def send(self, target: str, text: str) -> None: @@ -195,13 +194,7 @@ def _pm(text: str, nick: str = "alice") -> Message: def _clear() -> None: - """Reset module-level state between tests.""" - for task in _pollers.values(): - if task and not task.done(): - task.cancel() - _pollers.clear() - _channels.clear() - _errors.clear() + """No-op -- state is per-bot now, each _FakeBot starts fresh.""" def _fake_fetch_ok(url, etag="", last_modified=""): @@ -491,8 +484,8 @@ class TestCmdYtFollow: assert data["name"] == "3b1b" assert data["channel"] == "#test" assert len(data["seen"]) == 3 - assert "#test:3b1b" in _pollers - _stop_poller("#test:3b1b") + assert "#test:3b1b" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:3b1b") await asyncio.sleep(0) asyncio.run(inner()) @@ -679,7 +672,7 @@ class TestCmdYtUnfollow: await cmd_yt(bot, _msg("!yt unfollow delfeed")) assert "Unfollowed 'delfeed'" in bot.replied[0] assert _load(bot, "#test:delfeed") is None - assert "#test:delfeed" not in _pollers + assert "#test:delfeed" not in _ps(bot)["pollers"] await asyncio.sleep(0) asyncio.run(inner()) @@ -876,7 +869,7 @@ class TestPollOnce: } key = "#test:f304" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_304): @@ -896,13 +889,13 @@ class TestPollOnce: } key = "#test:ferr" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_error): await _poll_once(bot, key) await _poll_once(bot, key) - assert _errors[key] == 2 + assert _ps(bot)["errors"][key] == 2 updated = _load(bot, key) assert updated["last_error"] == "Connection refused" @@ -939,7 +932,7 @@ class TestPollOnce: } key = "#test:big" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): with ( @@ -964,7 +957,7 @@ class TestPollOnce: } key = "#test:quiet" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): @@ -987,7 +980,7 @@ class TestPollOnce: } key = "#test:etag" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): with patch.object(_mod, "_fetch_feed", _fake_fetch_ok): @@ -1015,10 +1008,10 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:restored" in _pollers - task = _pollers["#test:restored"] + assert "#test:restored" in _ps(bot)["pollers"] + task = _ps(bot)["pollers"]["#test:restored"] assert not task.done() - _stop_poller("#test:restored") + _stop_poller(bot, "#test:restored") await asyncio.sleep(0) asyncio.run(inner()) @@ -1035,9 +1028,9 @@ class TestRestore: async def inner(): dummy = asyncio.create_task(asyncio.sleep(9999)) - _pollers["#test:active"] = dummy + _ps(bot)["pollers"]["#test:active"] = dummy _restore(bot) - assert _pollers["#test:active"] is dummy + assert _ps(bot)["pollers"]["#test:active"] is dummy dummy.cancel() await asyncio.sleep(0) @@ -1056,12 +1049,12 @@ class TestRestore: async def inner(): done_task = asyncio.create_task(asyncio.sleep(0)) await done_task - _pollers["#test:done"] = done_task + _ps(bot)["pollers"]["#test:done"] = done_task _restore(bot) - new_task = _pollers["#test:done"] + new_task = _ps(bot)["pollers"]["#test:done"] assert new_task is not done_task assert not new_task.done() - _stop_poller("#test:done") + _stop_poller(bot, "#test:done") await asyncio.sleep(0) asyncio.run(inner()) @@ -1073,7 +1066,7 @@ class TestRestore: async def inner(): _restore(bot) - assert "#test:bad" not in _pollers + assert "#test:bad" not in _ps(bot)["pollers"] asyncio.run(inner()) @@ -1090,8 +1083,8 @@ class TestRestore: async def inner(): msg = _msg("", target="botname") await on_connect(bot, msg) - assert "#test:conn" in _pollers - _stop_poller("#test:conn") + assert "#test:conn" in _ps(bot)["pollers"] + _stop_poller(bot, "#test:conn") await asyncio.sleep(0) asyncio.run(inner()) @@ -1112,16 +1105,17 @@ class TestPollerManagement: } key = "#test:mgmt" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): _start_poller(bot, key) - assert key in _pollers - assert not _pollers[key].done() - _stop_poller(key) + ps = _ps(bot) + assert key in ps["pollers"] + assert not ps["pollers"][key].done() + _stop_poller(bot, key) await asyncio.sleep(0) - assert key not in _pollers - assert key not in _channels + assert key not in ps["pollers"] + assert key not in ps["channels"] asyncio.run(inner()) @@ -1135,21 +1129,23 @@ class TestPollerManagement: } key = "#test:idem" _save(bot, key, data) - _channels[key] = data + _ps(bot)["channels"][key] = data async def inner(): _start_poller(bot, key) - first = _pollers[key] + ps = _ps(bot) + first = ps["pollers"][key] _start_poller(bot, key) - assert _pollers[key] is first - _stop_poller(key) + assert ps["pollers"][key] is first + _stop_poller(bot, key) await asyncio.sleep(0) asyncio.run(inner()) def test_stop_nonexistent(self): _clear() - _stop_poller("#test:nonexistent") + bot = _FakeBot() + _stop_poller(bot, "#test:nonexistent") # ---------------------------------------------------------------------------