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 list # List alerts
!alert check <name> # Force-poll now
!alert info <id> # Show full result details
!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),
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 <id>` to see full
details. No API credentials needed. Persists across restarts. History stored in
`data/alert_history.db`.
## SearX

View File

@@ -123,7 +123,7 @@ format = "text" # Log format: "text" (default) or "json"
| `!username <user>` | Check username across ~25 services |
| `!username <user> <service>` | Check single service |
| `!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 |
### Command Shorthand
@@ -670,6 +670,7 @@ supported platforms simultaneously.
!alert del <name> Remove alert (admin)
!alert list List alerts
!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)
```
@@ -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/<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`,
`hn`, `gh`
`hn`, `gh` and `<id>` 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)

View File

@@ -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 <name> Remove alert (admin)
!alert list List alerts
!alert check <name> Force-poll now
!alert info <id> Show full details for a result
!alert history <name> [n] Show recent results (default 5)
"""
parts = message.text.split(None, 3)
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
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 <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) -------------------------------------------
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 <add|del|list|check|history> [args]")
await bot.reply(message, "Usage: !alert <add|del|list|check|info|history> [args]")