feat: add multi-server support
Connect to multiple IRC servers concurrently from a single config file. Plugins are loaded once and shared; per-server state is isolated via separate SQLite databases and per-bot runtime state (bot._pstate). - Add build_server_configs() for [servers.*] config layout - Bot.__init__ gains name parameter, _pstate dict for plugin isolation - cli.py runs multiple bots via asyncio.gather - 9 stateful plugins migrated from module-level dicts to _ps(bot) pattern - Backward compatible: legacy [server] config works unchanged Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
102
plugins/alert.py
102
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,32 +118,35 @@ def _delete_saved(bot, rid: str) -> None:
|
||||
bot.state.delete("remind", rid)
|
||||
|
||||
|
||||
# ---- In-memory tracking -----------------------------------------------------
|
||||
# ---- Per-bot runtime state --------------------------------------------------
|
||||
|
||||
# {rid: (task, target, nick, label, created, repeating)}
|
||||
_reminders: dict[str, tuple[asyncio.Task, str, str, str, str, bool]] = {}
|
||||
# Reverse lookup: (target, nick) -> [rid, ...]
|
||||
_by_user: dict[tuple[str, str], list[str]] = {}
|
||||
# Calendar-based rids (persisted)
|
||||
_calendar: set[str] = set()
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
return bot._pstate.setdefault("remind", {
|
||||
"reminders": {},
|
||||
"by_user": {},
|
||||
"calendar": set(),
|
||||
})
|
||||
|
||||
|
||||
def _cleanup(rid: str, target: str, nick: str) -> None:
|
||||
def _cleanup(bot, rid: str, target: str, nick: str) -> None:
|
||||
"""Remove a reminder from tracking structures."""
|
||||
_reminders.pop(rid, None)
|
||||
_calendar.discard(rid)
|
||||
ps = _ps(bot)
|
||||
ps["reminders"].pop(rid, None)
|
||||
ps["calendar"].discard(rid)
|
||||
ukey = (target, nick)
|
||||
if ukey in _by_user:
|
||||
_by_user[ukey] = [r for r in _by_user[ukey] if r != rid]
|
||||
if not _by_user[ukey]:
|
||||
del _by_user[ukey]
|
||||
if ukey in ps["by_user"]:
|
||||
ps["by_user"][ukey] = [r for r in ps["by_user"][ukey] if r != rid]
|
||||
if not ps["by_user"][ukey]:
|
||||
del ps["by_user"][ukey]
|
||||
|
||||
|
||||
def _track(rid: str, task: asyncio.Task, target: str, nick: str,
|
||||
def _track(bot, rid: str, task: asyncio.Task, target: str, nick: str,
|
||||
label: str, created: str, repeating: bool) -> None:
|
||||
"""Add a reminder to in-memory tracking."""
|
||||
_reminders[rid] = (task, target, nick, label, created, repeating)
|
||||
_by_user.setdefault((target, nick), []).append(rid)
|
||||
ps = _ps(bot)
|
||||
ps["reminders"][rid] = (task, target, nick, label, created, repeating)
|
||||
ps["by_user"].setdefault((target, nick), []).append(rid)
|
||||
|
||||
|
||||
# ---- Coroutines -------------------------------------------------------------
|
||||
@@ -159,7 +162,7 @@ async def _remind_once(bot, rid: str, target: str, nick: str, label: str,
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
_cleanup(rid, target, nick)
|
||||
_cleanup(bot, rid, target, nick)
|
||||
|
||||
|
||||
async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
|
||||
@@ -174,7 +177,7 @@ async def _remind_repeat(bot, rid: str, target: str, nick: str, label: str,
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
_cleanup(rid, target, nick)
|
||||
_cleanup(bot, rid, target, nick)
|
||||
|
||||
|
||||
async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
|
||||
@@ -191,7 +194,7 @@ async def _schedule_at(bot, rid: str, target: str, nick: str, label: str,
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
_cleanup(rid, target, nick)
|
||||
_cleanup(bot, rid, target, nick)
|
||||
|
||||
|
||||
async def _schedule_yearly(bot, rid: str, target: str, nick: str,
|
||||
@@ -219,16 +222,17 @@ async def _schedule_yearly(bot, rid: str, target: str, nick: str,
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
_cleanup(rid, target, nick)
|
||||
_cleanup(bot, rid, target, nick)
|
||||
|
||||
|
||||
# ---- Restore on connect -----------------------------------------------------
|
||||
|
||||
def _restore(bot) -> None:
|
||||
"""Restore persisted calendar reminders from bot.state."""
|
||||
ps = _ps(bot)
|
||||
for rid in bot.state.keys("remind"):
|
||||
# Skip if already active
|
||||
entry = _reminders.get(rid)
|
||||
entry = ps["reminders"].get(rid)
|
||||
if entry and not entry[0].done():
|
||||
continue
|
||||
raw = bot.state.get("remind", rid)
|
||||
@@ -272,8 +276,8 @@ def _restore(bot) -> None:
|
||||
else:
|
||||
continue
|
||||
|
||||
_calendar.add(rid)
|
||||
_track(rid, task, target, nick, label, created, rtype == "yearly")
|
||||
ps["calendar"].add(rid)
|
||||
_track(bot, rid, task, target, nick, label, created, rtype == "yearly")
|
||||
|
||||
|
||||
@event("001")
|
||||
@@ -311,12 +315,13 @@ async def cmd_remind(bot, message):
|
||||
|
||||
# ---- List ----------------------------------------------------------------
|
||||
if sub == "list":
|
||||
rids = _by_user.get(ukey, [])
|
||||
ps = _ps(bot)
|
||||
rids = ps["by_user"].get(ukey, [])
|
||||
active = []
|
||||
for rid in rids:
|
||||
entry = _reminders.get(rid)
|
||||
entry = ps["reminders"].get(rid)
|
||||
if entry and not entry[0].done():
|
||||
if rid in _calendar:
|
||||
if rid in ps["calendar"]:
|
||||
# Show next fire time
|
||||
raw = bot.state.get("remind", rid)
|
||||
if raw:
|
||||
@@ -347,7 +352,7 @@ async def cmd_remind(bot, message):
|
||||
if not rid:
|
||||
await bot.reply(message, "Usage: !remind cancel <id>")
|
||||
return
|
||||
entry = _reminders.get(rid)
|
||||
entry = _ps(bot)["reminders"].get(rid)
|
||||
if entry and not entry[0].done() and entry[2] == nick:
|
||||
entry[0].cancel()
|
||||
_delete_saved(bot, rid)
|
||||
@@ -397,11 +402,11 @@ async def cmd_remind(bot, message):
|
||||
"created": created,
|
||||
}
|
||||
_save(bot, rid, data)
|
||||
_calendar.add(rid)
|
||||
_ps(bot)["calendar"].add(rid)
|
||||
task = asyncio.create_task(
|
||||
_schedule_at(bot, rid, target, nick, label, fire_utc, created),
|
||||
)
|
||||
_track(rid, task, target, nick, label, created, False)
|
||||
_track(bot, rid, task, target, nick, label, created, False)
|
||||
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
|
||||
await bot.reply(message, f"Reminder #{rid} set (at {local_str})")
|
||||
return
|
||||
@@ -459,12 +464,12 @@ async def cmd_remind(bot, message):
|
||||
"created": created,
|
||||
}
|
||||
_save(bot, rid, data)
|
||||
_calendar.add(rid)
|
||||
_ps(bot)["calendar"].add(rid)
|
||||
task = asyncio.create_task(
|
||||
_schedule_yearly(bot, rid, target, nick, label, fire_utc,
|
||||
month, day_raw, hour, minute, tz, created),
|
||||
)
|
||||
_track(rid, task, target, nick, label, created, True)
|
||||
_track(bot, rid, task, target, nick, label, created, True)
|
||||
local_str = fire_dt.strftime("%Y-%m-%d %H:%M")
|
||||
await bot.reply(message, f"Reminder #{rid} set (yearly {month_day}, next {local_str})")
|
||||
return
|
||||
@@ -501,7 +506,7 @@ async def cmd_remind(bot, message):
|
||||
_remind_once(bot, rid, target, nick, label, duration, created),
|
||||
)
|
||||
|
||||
_track(rid, task, target, nick, label, created, repeating)
|
||||
_track(bot, rid, task, target, nick, label, created, repeating)
|
||||
|
||||
kind = f"every {_format_duration(duration)}" if repeating else _format_duration(duration)
|
||||
await bot.reply(message, f"Reminder #{rid} set ({kind})")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.<name>]`` sections):
|
||||
Each ``[servers.<name>]`` 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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]}})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user