feat: add notif-vault desktop notification archive with webhook fanout
This commit is contained in:
commit
050d43c2b9
8 files changed
+386
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"maxHistory": 5000,
|
||||||
|
"ignoreApps": [],
|
||||||
|
"doNotDisturb": {
|
||||||
|
"enabled": false,
|
||||||
|
"start": "23:00",
|
||||||
|
"end": "08:00",
|
||||||
|
"allowUrgent": true
|
||||||
|
},
|
||||||
|
"webhooks": [
|
||||||
|
{
|
||||||
|
"name": "example-discord",
|
||||||
|
"url": "",
|
||||||
|
"enabled": false,
|
||||||
|
"appPatterns": ["*"],
|
||||||
|
"minUrgency": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
sourceDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
installDir="$HOME/.local/share/notif-vault-src"
|
||||||
|
binDir="$HOME/.local/bin"
|
||||||
|
autostartDir="$HOME/.config/autostart"
|
||||||
|
|
||||||
|
mkdir -p "$installDir" "$binDir" "$autostartDir"
|
||||||
|
rsync -a --delete "$sourceDir/notif_vault" "$installDir/"
|
||||||
|
cp "$sourceDir/config.default.json" "$installDir/config.default.json"
|
||||||
|
|
||||||
|
cat > "$binDir/notif-vault" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec env PYTHONPATH=$installDir python3 -m notif_vault.cli "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$binDir/notif-vault"
|
||||||
|
|
||||||
|
cat > "$binDir/notif-vault-daemon" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec env PYTHONPATH=$installDir python3 -m notif_vault.daemon
|
||||||
|
EOF
|
||||||
|
chmod +x "$binDir/notif-vault-daemon"
|
||||||
|
|
||||||
|
cat > "$autostartDir/notif-vault.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Notif Vault
|
||||||
|
Exec=$binDir/notif-vault-daemon
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "installed."
|
||||||
|
echo " notif-vault-daemon & # starts the background history logger"
|
||||||
|
echo " notif-vault list # search recent notifications"
|
||||||
|
echo " notif-vault list --app discord --since 2h"
|
||||||
|
echo " notif-vault tail # live-follow new notifications"
|
||||||
|
echo "config file: ~/.config/notif-vault/config.json (do-not-disturb window + webhook forwarding rules)"
|
||||||
Whitespace-only changes.
@@ -0,0 +1,78 @@
|
|||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
from notif_vault import storage
|
||||||
|
|
||||||
|
|
||||||
|
def formatTimestamp(epochSeconds):
|
||||||
|
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epochSeconds))
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(text, length):
|
||||||
|
text = (text or "").replace("\n", " ").strip()
|
||||||
|
return text if len(text) <= length else text[: length - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def printRows(rows):
|
||||||
|
if not rows:
|
||||||
|
print("(no notifications match)")
|
||||||
|
return
|
||||||
|
for notificationId, appName, summary, body, urgency, createdAt in rows:
|
||||||
|
print(f"[{formatTimestamp(createdAt)}] {appName}: {truncate(summary, 60)} - {truncate(body, 80)}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmdList(args):
|
||||||
|
rows = storage.queryNotifications(
|
||||||
|
appName=args.app, search=args.search, sinceSeconds=parseSince(args.since), limit=args.limit
|
||||||
|
)
|
||||||
|
printRows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def cmdTail(args):
|
||||||
|
seenIds = set()
|
||||||
|
print("watching for new notifications, ctrl-c to stop")
|
||||||
|
while True:
|
||||||
|
rows = storage.queryNotifications(limit=20)
|
||||||
|
for row in reversed(rows):
|
||||||
|
if row[0] not in seenIds:
|
||||||
|
seenIds.add(row[0])
|
||||||
|
printRows([row])
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def parseSince(sinceText):
|
||||||
|
if not sinceText:
|
||||||
|
return None
|
||||||
|
unit = sinceText[-1]
|
||||||
|
amount = float(sinceText[:-1])
|
||||||
|
multiplier = {"m": 60, "h": 3600, "d": 86400}.get(unit)
|
||||||
|
if multiplier is None:
|
||||||
|
raise ValueError("since must look like 30m, 2h or 1d")
|
||||||
|
return amount * multiplier
|
||||||
|
|
||||||
|
|
||||||
|
def buildParser():
|
||||||
|
parser = argparse.ArgumentParser(prog="notif-vault", description="searchable desktop notification history")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
listParser = subparsers.add_parser("list", help="list recent notifications")
|
||||||
|
listParser.add_argument("--app", help="filter by app name substring")
|
||||||
|
listParser.add_argument("--search", help="filter by text in summary/body")
|
||||||
|
listParser.add_argument("--since", help="only show notifications newer than this, e.g. 30m, 2h, 1d")
|
||||||
|
listParser.add_argument("--limit", type=int, default=50)
|
||||||
|
listParser.set_defaults(func=cmdList)
|
||||||
|
|
||||||
|
tailParser = subparsers.add_parser("tail", help="live-follow new notifications")
|
||||||
|
tailParser.set_defaults(func=cmdTail)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = buildParser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
configDir = Path.home() / ".config" / "notif-vault"
|
||||||
|
configPath = configDir / "config.json"
|
||||||
|
defaultConfigPath = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) / "config.default.json"
|
||||||
|
|
||||||
|
|
||||||
|
def loadConfig():
|
||||||
|
if configPath.exists():
|
||||||
|
with open(configPath) as f:
|
||||||
|
return json.load(f)
|
||||||
|
with open(defaultConfigPath) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def ensureUserConfig():
|
||||||
|
if configPath.exists():
|
||||||
|
return
|
||||||
|
configDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(defaultConfigPath) as f:
|
||||||
|
default = f.read()
|
||||||
|
with open(configPath, "w") as f:
|
||||||
|
f.write(default)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import fnmatch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import dbus
|
||||||
|
from dbus.mainloop.glib import DBusGMainLoop
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from notif_vault import storage, webhook
|
||||||
|
from notif_vault.config import ensureUserConfig, loadConfig
|
||||||
|
|
||||||
|
URGENCY_NAMES = {0: "low", 1: "normal", 2: "critical"}
|
||||||
|
|
||||||
|
|
||||||
|
def isIgnored(appName, config):
|
||||||
|
for pattern in config.get("ignoreApps", []):
|
||||||
|
if fnmatch.fnmatch(appName, pattern):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def isWithinDnd(config):
|
||||||
|
dnd = config.get("doNotDisturb", {})
|
||||||
|
if not dnd.get("enabled"):
|
||||||
|
return False
|
||||||
|
now = datetime.now().strftime("%H:%M")
|
||||||
|
start, end = dnd.get("start", "23:00"), dnd.get("end", "08:00")
|
||||||
|
if start <= end:
|
||||||
|
return start <= now < end
|
||||||
|
return now >= start or now < end
|
||||||
|
|
||||||
|
|
||||||
|
class NotifVaultDaemon:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = loadConfig()
|
||||||
|
DBusGMainLoop(set_as_default=True)
|
||||||
|
self.bus = dbus.SessionBus()
|
||||||
|
self.bus.add_match_string_non_blocking(
|
||||||
|
"eavesdrop=true,interface='org.freedesktop.Notifications',member='Notify'"
|
||||||
|
)
|
||||||
|
self.bus.add_message_filter(self.onMessage)
|
||||||
|
|
||||||
|
def onMessage(self, bus, message):
|
||||||
|
if message.get_member() != "Notify" or message.get_interface() != "org.freedesktop.Notifications":
|
||||||
|
return
|
||||||
|
args = message.get_args_list()
|
||||||
|
if len(args) < 8:
|
||||||
|
return
|
||||||
|
appName, _replacesId, icon, summary, body, _actions, hints, _expireTimeout = args[:8]
|
||||||
|
appName = str(appName)
|
||||||
|
if isIgnored(appName, self.config):
|
||||||
|
return
|
||||||
|
|
||||||
|
urgency = int(hints.get("urgency", 1)) if hasattr(hints, "get") else 1
|
||||||
|
storage.addNotification(appName, str(summary), str(body), urgency, str(icon))
|
||||||
|
storage.trimHistory(self.config.get("maxHistory", 5000))
|
||||||
|
notificationId = storage.lastInsertId()
|
||||||
|
|
||||||
|
if isWithinDnd(self.config) and not (
|
||||||
|
self.config.get("doNotDisturb", {}).get("allowUrgent", True) and urgency == 2
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
def onWebhookDone(sentNames):
|
||||||
|
if sentNames and notificationId:
|
||||||
|
storage.markWebhookSent(notificationId, sentNames)
|
||||||
|
|
||||||
|
webhook.dispatchAsync(appName, str(summary), str(body), urgency, self.config, onDone=onWebhookDone)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ensureUserConfig()
|
||||||
|
NotifVaultDaemon()
|
||||||
|
GLib.MainLoop().run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
dbDir = Path.home() / ".local" / "share" / "notif-vault"
|
||||||
|
dbPath = dbDir / "notifications.db"
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
dbDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(dbPath)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
appName TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
body TEXT,
|
||||||
|
urgency INTEGER,
|
||||||
|
icon TEXT,
|
||||||
|
createdAt REAL,
|
||||||
|
webhookSentTo TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idxCreatedAt ON notifications(createdAt)")
|
||||||
|
connection.execute("CREATE INDEX IF NOT EXISTS idxAppName ON notifications(appName)")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def addNotification(appName, summary, body, urgency, icon):
|
||||||
|
connection = connect()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO notifications (appName, summary, body, urgency, icon, createdAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(appName, summary, body, urgency, icon, time.time()),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def markWebhookSent(notificationId, sentNames):
|
||||||
|
connection = connect()
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE notifications SET webhookSentTo = ? WHERE id = ?",
|
||||||
|
(",".join(sentNames), notificationId),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def trimHistory(maxHistory):
|
||||||
|
connection = connect()
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM notifications WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)",
|
||||||
|
(maxHistory,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def lastInsertId():
|
||||||
|
connection = connect()
|
||||||
|
row = connection.execute("SELECT id FROM notifications ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
connection.close()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def queryNotifications(appName=None, search=None, sinceSeconds=None, limit=50):
|
||||||
|
connection = connect()
|
||||||
|
clauses = []
|
||||||
|
params = []
|
||||||
|
if appName:
|
||||||
|
clauses.append("appName LIKE ?")
|
||||||
|
params.append(f"%{appName}%")
|
||||||
|
if search:
|
||||||
|
clauses.append("(summary LIKE ? OR body LIKE ?)")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
if sinceSeconds:
|
||||||
|
clauses.append("createdAt >= ?")
|
||||||
|
params.append(time.time() - sinceSeconds)
|
||||||
|
whereClause = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT id, appName, summary, body, urgency, createdAt FROM notifications {whereClause} ORDER BY id DESC LIMIT ?",
|
||||||
|
(*params, limit),
|
||||||
|
).fetchall()
|
||||||
|
connection.close()
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logDir = Path.home() / ".local" / "share" / "notif-vault"
|
||||||
|
logPath = logDir / "webhook.log"
|
||||||
|
|
||||||
|
|
||||||
|
def logLine(line):
|
||||||
|
logDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(logPath, "a") as logFile:
|
||||||
|
logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def matchingWebhooks(appName, urgency, config):
|
||||||
|
matches = []
|
||||||
|
for webhook in config.get("webhooks", []):
|
||||||
|
if not webhook.get("enabled"):
|
||||||
|
continue
|
||||||
|
if urgency < webhook.get("minUrgency", 0):
|
||||||
|
continue
|
||||||
|
patterns = webhook.get("appPatterns", ["*"])
|
||||||
|
if any(fnmatch.fnmatch(appName, pattern) for pattern in patterns):
|
||||||
|
matches.append(webhook)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def sendWebhook(webhook, appName, summary, body):
|
||||||
|
payload = {"appName": appName, "summary": summary, "body": body, "timestamp": time.time()}
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
headers.update(webhook.get("headers", {}))
|
||||||
|
request = urllib.request.Request(webhook["url"], data=data, headers=headers, method=webhook.get("method", "POST"))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=webhook.get("timeoutSeconds", 4)) as response:
|
||||||
|
logLine(f"webhook '{webhook.get('name')}' -> {response.status}")
|
||||||
|
return True
|
||||||
|
except Exception as sendError:
|
||||||
|
logLine(f"webhook '{webhook.get('name')}' failed: {sendError}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def dispatchAsync(appName, summary, body, urgency, config, onDone=None):
|
||||||
|
matches = matchingWebhooks(appName, urgency, config)
|
||||||
|
if not matches:
|
||||||
|
if onDone:
|
||||||
|
onDone([])
|
||||||
|
return
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
sentNames = []
|
||||||
|
for webhook in matches:
|
||||||
|
if sendWebhook(webhook, appName, summary, body):
|
||||||
|
sentNames.append(webhook.get("name", webhook["url"]))
|
||||||
|
if onDone:
|
||||||
|
onDone(sentNames)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
Reference in new issue
Block a user