diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index cff100d..9c05838 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -341,13 +341,14 @@ No API credentials needed (uses public GQL endpoint). !alert del # Remove alert (admin) !alert list # List alerts !alert check # Force-poll now +!alert history [n] # Show recent results (default 5) ``` Searches keywords across YouTube (InnerTube), Twitch (GQL), and SearXNG simultaneously. Names: lowercase alphanumeric + hyphens, 1-20 chars. Keywords: 1-100 chars. Max 20 alerts/channel. Polls every 5min. Max 5 announcements per platform per cycle. Format: `[name/yt] Title -- URL`, `[name/tw] Title -- URL`, or `[name/sx] Title -- URL`. -No API credentials needed. Persists across restarts. +No API credentials needed. Persists across restarts. History stored in `data/alert_history.db`. ## SearX diff --git a/docs/USAGE.md b/docs/USAGE.md index deecbbd..bfd7457 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -123,6 +123,7 @@ format = "text" # Log format: "text" (default) or "json" | `!username ` | Check username across ~25 services | | `!username ` | Check single service | | `!username list` | Show available services by category | +| `!alert ` | Keyword alert subscriptions across platforms | | `!searx ` | Search SearXNG and show top results | ### Command Shorthand @@ -666,6 +667,7 @@ simultaneously. !alert del Remove alert (admin) !alert list List alerts !alert check Force-poll now +!alert history [n] Show recent results (default 5, max 20) ``` - `add` and `del` require admin privileges @@ -692,5 +694,7 @@ Polling and announcements: - Each platform maintains its own seen list (capped at 200 per platform) - 5 consecutive errors doubles the poll interval (max 1 hour) - Subscriptions persist across bot restarts via `bot.state` +- Matched results are stored in `data/alert_history.db` (SQLite) - `list` shows error status indicators next to each alert - `check` forces an immediate poll across all platforms +- `history` queries stored results, most recent first diff --git a/plugins/alert.py b/plugins/alert.py index 32036eb..be2ce5a 100644 --- a/plugins/alert.py +++ b/plugins/alert.py @@ -6,9 +6,11 @@ import asyncio import json import logging import re +import sqlite3 import urllib.request from datetime import datetime, timezone from html.parser import HTMLParser +from pathlib import Path from derp.http import urlopen as _urlopen from derp.plugin import command, event @@ -37,6 +39,58 @@ _pollers: dict[str, asyncio.Task] = {} _subscriptions: dict[str, dict] = {} _errors: dict[str, int] = {} +# -- History database -------------------------------------------------------- + +_DB_PATH = Path("data/alert_history.db") +_conn: sqlite3.Connection | None = None + + +def _db() -> 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(""" + CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + alert TEXT NOT NULL, + backend TEXT NOT NULL, + item_id TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + date TEXT NOT NULL DEFAULT '', + found_at TEXT NOT NULL + ) + """) + _conn.execute( + "CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)" + ) + _conn.commit() + return _conn + + +def _save_result(channel: str, alert: str, backend: str, item: dict) -> None: + """Persist a matched result to the history database.""" + db = _db() + db.execute( + "INSERT INTO results (channel, alert, backend, item_id, title, url, date, found_at)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + channel, + alert, + backend, + item.get("id", ""), + item.get("title", ""), + item.get("url", ""), + item.get("date", ""), + datetime.now(timezone.utc).isoformat(), + ), + ) + db.commit() + # -- Pure helpers ------------------------------------------------------------ @@ -405,6 +459,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: if url: line += f" -- {url}" await bot.send(channel, line) + _save_result(channel, name, tag, item) for item in new_items: seen_list.append(item["id"]) @@ -480,7 +535,7 @@ async def on_connect(bot, message): # -- Command handler --------------------------------------------------------- -@command("alert", help="Alert: !alert add|del|list|check") +@command("alert", help="Alert: !alert add|del|list|check|history") async def cmd_alert(bot, message): """Per-channel keyword alert subscriptions across platforms. @@ -489,10 +544,11 @@ async def cmd_alert(bot, message): !alert del Remove alert (admin) !alert list List alerts !alert check Force-poll now + !alert history [n] Show recent results (default 5) """ parts = message.text.split(None, 3) if len(parts) < 2: - await bot.reply(message, "Usage: !alert [args]") + await bot.reply(message, "Usage: !alert [args]") return sub = parts[1].lower() @@ -545,6 +601,44 @@ async def cmd_alert(bot, message): await bot.reply(message, f"{name}: checked") return + # -- history (any user, channel only) ------------------------------------ + if sub == "history": + if not message.is_channel: + await bot.reply(message, "Use this command in a channel") + return + if len(parts) < 3: + await bot.reply(message, "Usage: !alert history [n]") + return + name = parts[2].lower() + channel = message.target + key = _state_key(channel, name) + if _load(bot, key) is None: + await bot.reply(message, f"No alert '{name}' in this channel") + return + limit = 5 + if len(parts) >= 4: + try: + limit = max(1, min(int(parts[3]), 20)) + except ValueError: + limit = 5 + db = _db() + rows = db.execute( + "SELECT backend, title, url, date, found_at FROM results" + " WHERE channel = ? AND alert = ? ORDER BY id DESC LIMIT ?", + (channel, name, limit), + ).fetchall() + if not rows: + await bot.reply(message, f"{name}: no history yet") + return + for backend, title, url, date, found_at in reversed(rows): + ts = found_at[:10] + title = _truncate(title) if title else "(no title)" + line = f"[{name}/{backend}] ({date or ts}) {title}" + if url: + line += f" -- {url}" + await bot.reply(message, line) + return + # -- add (admin, channel only) ------------------------------------------- if sub == "add": if not bot._is_admin(message): @@ -645,4 +739,4 @@ async def cmd_alert(bot, message): await bot.reply(message, f"Removed '{name}'") return - await bot.reply(message, "Usage: !alert [args]") + await bot.reply(message, "Usage: !alert [args]")