diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 20:43:28 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 20:43:28 +0000 |
| commit | 7f920ca63af5329c19a0e5a879c649c594beea35 (patch) | |
| tree | 803150e7c895d3232bad35c729aad647aaa54348 /internal/notify/webpush.go | |
| parent | 072652f617653dce74368cedb42b88189e5014fb (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.go | 106 |
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 +} |
