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:
user
2026-02-15 22:09:01 +01:00
parent 181d6dbfad
commit 122785b1f3
3 changed files with 103 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]")