summaryrefslogtreecommitdiff
path: root/internal/notify/webpush.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /internal/notify/webpush.go
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (📁) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/notify/webpush.go')
-rw-r--r--internal/notify/webpush.go106
1 files changed, 106 insertions, 0 deletions
diff --git a/internal/notify/webpush.go b/internal/notify/webpush.go
new file mode 100644
index 0000000..e118a43
--- /dev/null
+++ b/internal/notify/webpush.go
@@ -0,0 +1,106 @@
+package notify
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+
+ webpush "github.com/SherClockHolmes/webpush-go"
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// PushSubscriptionStore is the minimal storage interface needed by WebPushNotifier.
+type PushSubscriptionStore interface {
+ ListPushSubscriptions() ([]storage.PushSubscription, error)
+}
+
+// WebPushNotifier sends web push notifications to all registered subscribers.
+type WebPushNotifier struct {
+ Store PushSubscriptionStore
+ VAPIDPublicKey string
+ VAPIDPrivateKey string
+ VAPIDEmail string
+ Logger *slog.Logger
+}
+
+// notificationContent derives urgency, title, body, and tag from a notify Event.
+// Exported only for tests; use lowercase in production code via this same file.
+func notificationContent(ev Event) (urgency, title, body, tag string) {
+ tag = "task-" + ev.TaskID
+ switch ev.Status {
+ case "BLOCKED":
+ urgency = "urgent"
+ title = "Needs input"
+ body = fmt.Sprintf("%s is waiting for your response", ev.TaskName)
+ case "FAILED", "BUDGET_EXCEEDED", "TIMED_OUT":
+ urgency = "high"
+ title = "Task failed"
+ if ev.Error != "" {
+ body = fmt.Sprintf("%s failed: %s", ev.TaskName, ev.Error)
+ } else {
+ body = fmt.Sprintf("%s failed", ev.TaskName)
+ }
+ case "COMPLETED":
+ urgency = "low"
+ title = "Task done"
+ body = fmt.Sprintf("%s completed ($%.2f)", ev.TaskName, ev.CostUSD)
+ default:
+ urgency = "normal"
+ title = "Task update"
+ body = fmt.Sprintf("%s: %s", ev.TaskName, ev.Status)
+ }
+ return
+}
+
+// Notify sends a web push notification to all registered subscribers.
+func (n *WebPushNotifier) Notify(ev Event) error {
+ subs, err := n.Store.ListPushSubscriptions()
+ if err != nil {
+ return fmt.Errorf("listing push subscriptions: %w", err)
+ }
+ if len(subs) == 0 {
+ return nil
+ }
+
+ urgency, title, body, tag := notificationContent(ev)
+
+ payload := map[string]string{
+ "title": title,
+ "body": body,
+ "tag": tag,
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshaling push payload: %w", err)
+ }
+
+ opts := &webpush.Options{
+ Subscriber: n.VAPIDEmail,
+ VAPIDPublicKey: n.VAPIDPublicKey,
+ VAPIDPrivateKey: n.VAPIDPrivateKey,
+ Urgency: webpush.Urgency(urgency),
+ TTL: 86400,
+ }
+
+ var lastErr error
+ for _, sub := range subs {
+ wSub := &webpush.Subscription{
+ Endpoint: sub.Endpoint,
+ Keys: webpush.Keys{
+ P256dh: sub.P256DHKey,
+ Auth: sub.AuthKey,
+ },
+ }
+ resp, sendErr := webpush.SendNotification(data, wSub, opts)
+ if sendErr != nil {
+ n.Logger.Error("webpush send failed", "endpoint", sub.Endpoint, "error", sendErr)
+ lastErr = sendErr
+ continue
+ }
+ resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ n.Logger.Warn("webpush returned error status", "endpoint", sub.Endpoint, "status", resp.StatusCode)
+ }
+ }
+ return lastErr
+}