feat: persist alert results to SQLite history table
Matched results were announced then discarded. Add a dedicated SQLite database (data/alert_history.db) to store every announced result with channel, alert name, backend, title, URL, date, and timestamp. Add !alert history <name> [n] subcommand to query recent results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -341,13 +341,14 @@ 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 history <name> [n] # Show recent results (default 5)
|
||||||
```
|
```
|
||||||
|
|
||||||
Searches keywords across YouTube (InnerTube), Twitch (GQL), and SearXNG simultaneously.
|
Searches keywords across YouTube (InnerTube), Twitch (GQL), and SearXNG simultaneously.
|
||||||
Names: lowercase alphanumeric + hyphens, 1-20 chars. Keywords: 1-100 chars.
|
Names: lowercase alphanumeric + hyphens, 1-20 chars. Keywords: 1-100 chars.
|
||||||
Max 20 alerts/channel. Polls every 5min. Max 5 announcements per platform per cycle.
|
Max 20 alerts/channel. Polls every 5min. Max 5 announcements per platform per cycle.
|
||||||
Format: `[name/yt] Title -- URL`, `[name/tw] Title -- URL`, or `[name/sx] Title -- URL`.
|
Format: `[name/yt] Title -- URL`, `[name/tw] Title -- URL`, or `[name/sx] Title -- URL`.
|
||||||
No API credentials needed. Persists across restarts.
|
No API credentials needed. Persists across restarts. History stored in `data/alert_history.db`.
|
||||||
|
|
||||||
## SearX
|
## SearX
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +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 |
|
||||||
| `!searx <query>` | Search SearXNG and show top results |
|
| `!searx <query>` | Search SearXNG and show top results |
|
||||||
|
|
||||||
### Command Shorthand
|
### Command Shorthand
|
||||||
@@ -666,6 +667,7 @@ 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 history <name> [n] Show recent results (default 5, max 20)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `add` and `del` require admin privileges
|
- `add` and `del` require admin privileges
|
||||||
@@ -692,5 +694,7 @@ Polling and announcements:
|
|||||||
- 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)
|
||||||
- Subscriptions persist across bot restarts via `bot.state`
|
- Subscriptions persist across bot restarts via `bot.state`
|
||||||
|
- Matched results are stored in `data/alert_history.db` (SQLite)
|
||||||
- `list` shows error status indicators next to each alert
|
- `list` shows error status indicators next to each alert
|
||||||
- `check` forces an immediate poll across all platforms
|
- `check` forces an immediate poll across all platforms
|
||||||
|
- `history` queries stored results, most recent first
|
||||||
|
|||||||
100
plugins/alert.py
100
plugins/alert.py
@@ -6,9 +6,11 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from derp.http import urlopen as _urlopen
|
from derp.http import urlopen as _urlopen
|
||||||
from derp.plugin import command, event
|
from derp.plugin import command, event
|
||||||
@@ -37,6 +39,58 @@ _pollers: dict[str, asyncio.Task] = {}
|
|||||||
_subscriptions: dict[str, dict] = {}
|
_subscriptions: dict[str, dict] = {}
|
||||||
_errors: dict[str, int] = {}
|
_errors: dict[str, int] = {}
|
||||||
|
|
||||||
|
# -- History database --------------------------------------------------------
|
||||||
|
|
||||||
|
_DB_PATH = Path("data/alert_history.db")
|
||||||
|
_conn: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> sqlite3.Connection:
|
||||||
|
"""Lazy-init the history database connection and schema."""
|
||||||
|
global _conn
|
||||||
|
if _conn is not None:
|
||||||
|
return _conn
|
||||||
|
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_conn = sqlite3.connect(str(_DB_PATH))
|
||||||
|
_conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
alert TEXT NOT NULL,
|
||||||
|
backend TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL DEFAULT '',
|
||||||
|
found_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
_conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_results_alert ON results(channel, alert)"
|
||||||
|
)
|
||||||
|
_conn.commit()
|
||||||
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
def _save_result(channel: str, alert: str, backend: str, item: dict) -> None:
|
||||||
|
"""Persist a matched result to the history database."""
|
||||||
|
db = _db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO results (channel, alert, backend, item_id, title, url, date, found_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
channel,
|
||||||
|
alert,
|
||||||
|
backend,
|
||||||
|
item.get("id", ""),
|
||||||
|
item.get("title", ""),
|
||||||
|
item.get("url", ""),
|
||||||
|
item.get("date", ""),
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
# -- Pure helpers ------------------------------------------------------------
|
# -- Pure helpers ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -405,6 +459,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
|
|||||||
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"])
|
||||||
@@ -480,7 +535,7 @@ async def on_connect(bot, message):
|
|||||||
|
|
||||||
# -- Command handler ---------------------------------------------------------
|
# -- Command handler ---------------------------------------------------------
|
||||||
|
|
||||||
@command("alert", help="Alert: !alert add|del|list|check")
|
@command("alert", help="Alert: !alert add|del|list|check|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.
|
||||||
|
|
||||||
@@ -489,10 +544,11 @@ 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 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> [args]")
|
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]")
|
||||||
return
|
return
|
||||||
|
|
||||||
sub = parts[1].lower()
|
sub = parts[1].lower()
|
||||||
@@ -545,6 +601,44 @@ async def cmd_alert(bot, message):
|
|||||||
await bot.reply(message, f"{name}: checked")
|
await bot.reply(message, f"{name}: checked")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# -- history (any user, channel only) ------------------------------------
|
||||||
|
if sub == "history":
|
||||||
|
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 history <name> [n]")
|
||||||
|
return
|
||||||
|
name = parts[2].lower()
|
||||||
|
channel = message.target
|
||||||
|
key = _state_key(channel, name)
|
||||||
|
if _load(bot, key) is None:
|
||||||
|
await bot.reply(message, f"No alert '{name}' in this channel")
|
||||||
|
return
|
||||||
|
limit = 5
|
||||||
|
if len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(parts[3]), 20))
|
||||||
|
except ValueError:
|
||||||
|
limit = 5
|
||||||
|
db = _db()
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT backend, title, url, date, found_at 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):
|
||||||
|
ts = found_at[:10]
|
||||||
|
title = _truncate(title) if title else "(no title)"
|
||||||
|
line = f"[{name}/{backend}] ({date or ts}) {title}"
|
||||||
|
if url:
|
||||||
|
line += f" -- {url}"
|
||||||
|
await bot.reply(message, line)
|
||||||
|
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):
|
||||||
@@ -645,4 +739,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> [args]")
|
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]")
|
||||||
|
|||||||
Reference in New Issue
Block a user