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