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 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
100
plugins/alert.py
100
plugins/alert.py
@@ -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]")
|
||||
|
||||
Reference in New Issue
Block a user