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 <id> for full details. Existing rows are
backfilled on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 23:20:56 +01:00
parent 5ded8186dd
commit daa3370433
3 changed files with 86 additions and 18 deletions

View File

@@ -342,6 +342,7 @@ No API credentials needed (uses public GQL endpoint).
!alert del <name> # Remove alert (admin) !alert del <name> # Remove alert (admin)
!alert list # List alerts !alert list # List alerts
!alert check <name> # Force-poll now !alert check <name> # Force-poll now
!alert info <id> # Show full result details
!alert history <name> [n] # Show recent results (default 5) !alert history <name> [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), Dailymotion (dm), PeerTube (pt), Bluesky (bs), Lemmy (ly), Odysee (od),
Archive.org (ia), Hacker News (hn), GitHub (gh). Names: lowercase alphanumeric + 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 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 5min. Format: `[name/yt/a8k2m] Title -- URL`. Use `!alert info <id>` to see full
across restarts. History stored in `data/alert_history.db`. details. No API credentials needed. Persists across restarts. History stored in
`data/alert_history.db`.
## SearX ## SearX

View File

@@ -123,7 +123,7 @@ format = "text" # Log format: "text" (default) or "json"
| `!username <user>` | Check username across ~25 services | | `!username <user>` | Check username across ~25 services |
| `!username <user> <service>` | Check single service | | `!username <user> <service>` | Check single service |
| `!username list` | Show available services by category | | `!username list` | Show available services by category |
| `!alert <add\|del\|list\|check\|history>` | Keyword alert subscriptions across platforms | | `!alert <add\|del\|list\|check\|info\|history>` | Keyword alert subscriptions across platforms |
| `!searx <query>` | Search SearXNG and show top results | | `!searx <query>` | Search SearXNG and show top results |
### Command Shorthand ### Command Shorthand
@@ -670,6 +670,7 @@ supported platforms simultaneously.
!alert del <name> Remove alert (admin) !alert del <name> Remove alert (admin)
!alert list List alerts !alert list List alerts
!alert check <name> Force-poll now !alert check <name> Force-poll now
!alert info <id> Show full details for a result
!alert history <name> [n] Show recent results (default 5, max 20) !alert history <name> [n] Show recent results (default 5, max 20)
``` ```
@@ -703,9 +704,9 @@ Polling and announcements:
- Alerts are polled every 5 minutes by default - Alerts are polled every 5 minutes by default
- On `add`, existing results are recorded without announcing (prevents flood) - On `add`, existing results are recorded without announcing (prevents flood)
- New results announced as `[name/<tag>] Title -- URL` where tag is one of: - New results announced as `[name/<tag>/<id>] Title -- URL` where tag is one of:
`yt`, `tw`, `sx`, `rd`, `ft`, `dg`, `gn`, `kk`, `dm`, `pt`, `bs`, `ly`, `od`, `ia`, `yt`, `tw`, `sx`, `rd`, `ft`, `dg`, `gn`, `kk`, `dm`, `pt`, `bs`, `ly`, `od`, `ia`,
`hn`, `gh` `hn`, `gh` and `<id>` is a short deterministic ID for use with `!alert info`
- Titles are truncated to 80 characters - Titles are truncated to 80 characters
- Each platform maintains its own seen list (capped at 200 per platform) - Each platform maintains its own seen list (capped at 200 per platform)
- 5 consecutive errors doubles the poll interval (max 1 hour) - 5 consecutive errors doubles the poll interval (max 1 hour)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import re import re
@@ -93,22 +94,42 @@ def _db() -> sqlite3.Connection:
title TEXT NOT NULL, title TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
date TEXT NOT NULL DEFAULT '', 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( _conn.execute(
"CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)" "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() _conn.commit()
return _conn return _conn
def _save_result(channel: str, alert: str, backend: str, item: dict) -> None: def _save_result(channel: str, alert: str, backend: str, item: dict) -> str:
"""Persist a matched result to the history database.""" """Persist a matched result to the history database. Returns short_id."""
short_id = _make_short_id(backend, item.get("id", ""))
db = _db() db = _db()
db.execute( db.execute(
"INSERT INTO results (channel, alert, backend, item_id, title, url, date, found_at)" "INSERT INTO results"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)", " (channel, alert, backend, item_id, title, url, date, found_at, short_id)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
( (
channel, channel,
alert, alert,
@@ -118,9 +139,11 @@ def _save_result(channel: str, alert: str, backend: str, item: dict) -> None:
item.get("url", ""), item.get("url", ""),
item.get("date", ""), item.get("date", ""),
datetime.now(timezone.utc).isoformat(), datetime.now(timezone.utc).isoformat(),
short_id,
), ),
) )
db.commit() db.commit()
return short_id
# -- Pure helpers ------------------------------------------------------------ # -- Pure helpers ------------------------------------------------------------
@@ -209,6 +232,18 @@ class _DDGParser(HTMLParser):
self.results.append((self._url, title)) 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: def _parse_date(raw: str) -> str:
"""Try to extract a YYYY-MM-DD date from a raw date string.""" """Try to extract a YYYY-MM-DD date from a raw date string."""
m = re.search(r"\d{4}-\d{2}-\d{2}", raw) 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"] channel = data["channel"]
name = data["name"] name = data["name"]
for item in matched: for item in matched:
short_id = _save_result(channel, name, tag, item)
title = _truncate(item["title"]) if item["title"] else "(no title)" title = _truncate(item["title"]) if item["title"] else "(no title)"
url = item["url"] url = item["url"]
date = item.get("date", "") date = item.get("date", "")
line = f"[{name}/{tag}]" line = f"[{name}/{tag}/{short_id}]"
if date: if date:
line += f" ({date})" line += f" ({date})"
line += f" {title}" line += f" {title}"
if url: if url:
line += f" -- {url}" line += f" -- {url}"
await bot.send(channel, line) await bot.send(channel, line)
_save_result(channel, name, tag, item)
for item in new_items: for item in new_items:
seen_list.append(item["id"]) seen_list.append(item["id"])
@@ -1286,7 +1321,7 @@ async def on_connect(bot, message):
# -- Command handler --------------------------------------------------------- # -- 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): async def cmd_alert(bot, message):
"""Per-channel keyword alert subscriptions across platforms. """Per-channel keyword alert subscriptions across platforms.
@@ -1295,11 +1330,12 @@ async def cmd_alert(bot, message):
!alert del <name> Remove alert (admin) !alert del <name> Remove alert (admin)
!alert list List alerts !alert list List alerts
!alert check <name> Force-poll now !alert check <name> Force-poll now
!alert info <id> Show full details for a result
!alert history <name> [n] Show recent results (default 5) !alert history <name> [n] Show recent results (default 5)
""" """
parts = message.text.split(None, 3) parts = message.text.split(None, 3)
if len(parts) < 2: if len(parts) < 2:
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]") await bot.reply(message, "Usage: !alert <add|del|list|check|info|history> [args]")
return return
sub = parts[1].lower() sub = parts[1].lower()
@@ -1374,22 +1410,51 @@ async def cmd_alert(bot, message):
limit = 5 limit = 5
db = _db() db = _db()
rows = db.execute( 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 ?", " WHERE channel = ? AND alert = ? ORDER BY id DESC LIMIT ?",
(channel, name, limit), (channel, name, limit),
).fetchall() ).fetchall()
if not rows: if not rows:
await bot.reply(message, f"{name}: no history yet") await bot.reply(message, f"{name}: no history yet")
return 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] ts = found_at[:10]
title = _truncate(title) if title else "(no title)" 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: if url:
line += f" -- {url}" line += f" -- {url}"
await bot.reply(message, line) await bot.reply(message, line)
return 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 <id>")
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) ------------------------------------------- # -- add (admin, channel only) -------------------------------------------
if sub == "add": if sub == "add":
if not bot._is_admin(message): if not bot._is_admin(message):
@@ -1490,4 +1555,4 @@ async def cmd_alert(bot, message):
await bot.reply(message, f"Removed '{name}'") await bot.reply(message, f"Removed '{name}'")
return return
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]") await bot.reply(message, "Usage: !alert <add|del|list|check|info|history> [args]")