From daa3370433cf3a30fa7a19f823deaa55a64f1c5f Mon Sep 17 00:00:00 2001 From: user Date: Sun, 15 Feb 2026 23:20:56 +0100 Subject: [PATCH] feat: add short IDs to alert results with !alert info command Each alert result gets a deterministic 8-char base36 ID derived from backend:item_id. IDs appear in announcements and history, and can be looked up with !alert info for full details. Existing rows are backfilled on startup. Co-Authored-By: Claude Opus 4.6 --- docs/CHEATSHEET.md | 6 ++- docs/USAGE.md | 7 ++-- plugins/alert.py | 91 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index e789428..53d1fbf 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -342,6 +342,7 @@ No API credentials needed (uses public GQL endpoint). !alert del # Remove alert (admin) !alert list # List alerts !alert check # Force-poll now +!alert info # Show full result details !alert history [n] # Show recent results (default 5) ``` @@ -350,8 +351,9 @@ Reddit (rd), Mastodon (ft), DuckDuckGo (dg), Google News (gn), Kick (kk), Dailymotion (dm), PeerTube (pt), Bluesky (bs), Lemmy (ly), Odysee (od), Archive.org (ia), Hacker News (hn), GitHub (gh). Names: lowercase alphanumeric + hyphens, 1-20 chars. Keywords: 1-100 chars. Max 20 alerts/channel. Polls every -5min. Format: `[name/yt] Title -- URL`, etc. No API credentials needed. Persists -across restarts. History stored in `data/alert_history.db`. +5min. Format: `[name/yt/a8k2m] Title -- URL`. Use `!alert info ` to see full +details. 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 c89ef57..7880271 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -123,7 +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 | +| `!alert ` | Keyword alert subscriptions across platforms | | `!searx ` | Search SearXNG and show top results | ### Command Shorthand @@ -670,6 +670,7 @@ supported platforms simultaneously. !alert del Remove alert (admin) !alert list List alerts !alert check Force-poll now +!alert info Show full details for a result !alert history [n] Show recent results (default 5, max 20) ``` @@ -703,9 +704,9 @@ Polling and announcements: - Alerts are polled every 5 minutes by default - On `add`, existing results are recorded without announcing (prevents flood) -- New results announced as `[name/] Title -- URL` where tag is one of: +- New results announced as `[name//] Title -- URL` where tag is one of: `yt`, `tw`, `sx`, `rd`, `ft`, `dg`, `gn`, `kk`, `dm`, `pt`, `bs`, `ly`, `od`, `ia`, - `hn`, `gh` + `hn`, `gh` and `` is a short deterministic ID for use with `!alert info` - Titles are truncated to 80 characters - Each platform maintains its own seen list (capped at 200 per platform) - 5 consecutive errors doubles the poll interval (max 1 hour) diff --git a/plugins/alert.py b/plugins/alert.py index 1ae8b53..b54f5dc 100644 --- a/plugins/alert.py +++ b/plugins/alert.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import re @@ -93,22 +94,42 @@ def _db() -> sqlite3.Connection: title TEXT NOT NULL, url TEXT NOT NULL, date TEXT NOT NULL DEFAULT '', - found_at TEXT NOT NULL + found_at TEXT NOT NULL, + short_id TEXT NOT NULL DEFAULT '' ) """) + try: + _conn.execute( + "ALTER TABLE results ADD COLUMN short_id TEXT NOT NULL DEFAULT ''" + ) + except sqlite3.OperationalError: + pass # column already exists _conn.execute( "CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)" ) + _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( + "SELECT id, backend, item_id FROM results WHERE short_id = ''" + ).fetchall(): + _conn.execute( + "UPDATE results SET short_id = ? WHERE id = ?", + (_make_short_id(backend, item_id), row_id), + ) _conn.commit() return _conn -def _save_result(channel: str, alert: str, backend: str, item: dict) -> None: - """Persist a matched result to the history database.""" +def _save_result(channel: str, alert: str, backend: str, item: dict) -> str: + """Persist a matched result to the history database. Returns short_id.""" + short_id = _make_short_id(backend, item.get("id", "")) db = _db() db.execute( - "INSERT INTO results (channel, alert, backend, item_id, title, url, date, found_at)" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO results" + " (channel, alert, backend, item_id, title, url, date, found_at, short_id)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ( channel, alert, @@ -118,9 +139,11 @@ def _save_result(channel: str, alert: str, backend: str, item: dict) -> None: item.get("url", ""), item.get("date", ""), datetime.now(timezone.utc).isoformat(), + short_id, ), ) db.commit() + return short_id # -- Pure helpers ------------------------------------------------------------ @@ -209,6 +232,18 @@ class _DDGParser(HTMLParser): self.results.append((self._url, title)) +def _make_short_id(backend: str, item_id: str) -> str: + """Deterministic 8-char base36 hash from backend:item_id.""" + digest = hashlib.sha256(f"{backend}:{item_id}".encode()).digest() + n = int.from_bytes(digest[:5], "big") + chars = "0123456789abcdefghijklmnopqrstuvwxyz" + parts = [] + while n: + n, r = divmod(n, 36) + parts.append(chars[r]) + return "".join(reversed(parts)) or "0" + + def _parse_date(raw: str) -> str: """Try to extract a YYYY-MM-DD date from a raw date string.""" m = re.search(r"\d{4}-\d{2}-\d{2}", raw) @@ -1200,17 +1235,17 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None: channel = data["channel"] name = data["name"] for item in matched: + short_id = _save_result(channel, name, tag, item) title = _truncate(item["title"]) if item["title"] else "(no title)" url = item["url"] date = item.get("date", "") - line = f"[{name}/{tag}]" + line = f"[{name}/{tag}/{short_id}]" if date: line += f" ({date})" line += f" {title}" 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"]) @@ -1286,7 +1321,7 @@ async def on_connect(bot, message): # -- Command handler --------------------------------------------------------- -@command("alert", help="Alert: !alert add|del|list|check|history") +@command("alert", help="Alert: !alert add|del|list|check|info|history") async def cmd_alert(bot, message): """Per-channel keyword alert subscriptions across platforms. @@ -1295,11 +1330,12 @@ async def cmd_alert(bot, message): !alert del Remove alert (admin) !alert list List alerts !alert check Force-poll now + !alert info Show full details for a result !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() @@ -1374,22 +1410,51 @@ async def cmd_alert(bot, message): limit = 5 db = _db() rows = db.execute( - "SELECT backend, title, url, date, found_at FROM results" + "SELECT backend, title, url, date, found_at, short_id 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): + for backend, title, url, date, found_at, short_id in reversed(rows): ts = found_at[:10] title = _truncate(title) if title else "(no title)" - line = f"[{name}/{backend}] ({date or ts}) {title}" + line = f"[{name}/{backend}/{short_id}] ({date or ts}) {title}" if url: line += f" -- {url}" await bot.reply(message, line) return + # -- info (any user, channel only) --------------------------------------- + if sub == "info": + 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 info ") + return + short_id = parts[2].lower() + channel = message.target + db = _db() + row = db.execute( + "SELECT alert, backend, title, url, date, found_at, short_id" + " FROM results WHERE short_id = ? AND channel = ? LIMIT 1", + (short_id, channel), + ).fetchone() + if not row: + await bot.reply(message, f"No result with id '{short_id}'") + return + alert, backend, title, url, date, found_at, sid = row + await bot.reply(message, f"[{alert}/{backend}/{sid}] {title or '(no title)'}") + if url: + await bot.reply(message, url) + await bot.reply( + message, + f"Date: {date or 'n/a'} | Found: {found_at[:19]}", + ) + return + # -- add (admin, channel only) ------------------------------------------- if sub == "add": if not bot._is_admin(message): @@ -1490,4 +1555,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]")