diff --git a/notif-vault/config.default.json b/notif-vault/config.default.json new file mode 100644 index 0000000..027b09c --- /dev/null +++ b/notif-vault/config.default.json @@ -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 + } + ] +} diff --git a/notif-vault/install.sh b/notif-vault/install.sh new file mode 100755 index 0000000..2d0c5c7 --- /dev/null +++ b/notif-vault/install.sh @@ -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" < "$binDir/notif-vault-daemon" < "$autostartDir/notif-vault.desktop" <= 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() diff --git a/notif-vault/notif_vault/storage.py b/notif-vault/notif_vault/storage.py new file mode 100644 index 0000000..cf73e66 --- /dev/null +++ b/notif-vault/notif_vault/storage.py @@ -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 diff --git a/notif-vault/notif_vault/webhook.py b/notif-vault/notif_vault/webhook.py new file mode 100644 index 0000000..98df679 --- /dev/null +++ b/notif-vault/notif_vault/webhook.py @@ -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()