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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user