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 list # List alerts
!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.
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.
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

View File

@@ -123,6 +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 |
| `!searx <query>` | Search SearXNG and show top results |
### Command Shorthand
@@ -666,6 +667,7 @@ simultaneously.
!alert del <name> Remove alert (admin)
!alert list List alerts
!alert check <name> Force-poll now
!alert history <name> [n] Show recent results (default 5, max 20)
```
- `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)
- 5 consecutive errors doubles the poll interval (max 1 hour)
- 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
- `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 logging
import re
import sqlite3
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from derp.http import urlopen as _urlopen
from derp.plugin import command, event
@@ -37,6 +39,58 @@ _pollers: dict[str, asyncio.Task] = {}
_subscriptions: dict[str, dict] = {}
_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 ------------------------------------------------------------
@@ -405,6 +459,7 @@ async def _poll_once(bot, key: str, announce: bool = True) -> None:
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"])
@@ -480,7 +535,7 @@ async def on_connect(bot, message):
# -- 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):
"""Per-channel keyword alert subscriptions across platforms.
@@ -489,10 +544,11 @@ async def cmd_alert(bot, message):
!alert del <name> Remove alert (admin)
!alert list List alerts
!alert check <name> Force-poll now
!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> [args]")
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]")
return
sub = parts[1].lower()
@@ -545,6 +601,44 @@ async def cmd_alert(bot, message):
await bot.reply(message, f"{name}: checked")
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) -------------------------------------------
if sub == "add":
if not bot._is_admin(message):
@@ -645,4 +739,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> [args]")
await bot.reply(message, "Usage: !alert <add|del|list|check|history> [args]")