252 lines
5.6 KiB
Go
252 lines
5.6 KiB
Go
package push
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/SherClockHolmes/webpush-go"
|
|
|
|
"g.fc.run/misc/byre/internal/api"
|
|
"g.fc.run/misc/byre/internal/store"
|
|
)
|
|
|
|
type Message struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type Notifier struct {
|
|
store *store.Store
|
|
keys store.VAPIDKeys
|
|
window time.Duration
|
|
items chan api.InboxItem
|
|
done chan struct{}
|
|
exited chan struct{}
|
|
}
|
|
|
|
func New(st *store.Store, window time.Duration) (*Notifier, error) {
|
|
ctx := context.Background()
|
|
keys, err := st.GetVAPIDKeys(ctx)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
priv, pub, err := webpush.GenerateVAPIDKeys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys, err = st.InitVAPIDKeys(ctx, store.VAPIDKeys{Public: pub, Private: priv})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
n := &Notifier{
|
|
store: st,
|
|
keys: keys,
|
|
window: window,
|
|
items: make(chan api.InboxItem, 256),
|
|
done: make(chan struct{}),
|
|
exited: make(chan struct{}),
|
|
}
|
|
go n.run()
|
|
return n, nil
|
|
}
|
|
|
|
func (n *Notifier) PublicKey() string {
|
|
return n.keys.Public
|
|
}
|
|
|
|
func (n *Notifier) Close() {
|
|
close(n.done)
|
|
<-n.exited
|
|
}
|
|
|
|
func (n *Notifier) Observe(ev api.Event) {
|
|
if ev.Kind != "inbox.created" {
|
|
return
|
|
}
|
|
|
|
b, err := json.Marshal(ev.Payload)
|
|
if err != nil {
|
|
slog.Error("push: encode inbox item", "err", err)
|
|
return
|
|
}
|
|
item := api.InboxItem{}
|
|
err = json.Unmarshal(b, &item)
|
|
if err != nil {
|
|
slog.Error("push: decode inbox item", "err", err)
|
|
return
|
|
}
|
|
|
|
select {
|
|
case n.items <- item:
|
|
default:
|
|
slog.Warn("push: queue full, dropping notification", "item", item.Id)
|
|
}
|
|
}
|
|
|
|
func (n *Notifier) run() {
|
|
defer close(n.exited)
|
|
|
|
pending := []api.InboxItem{}
|
|
var flush <-chan time.Time
|
|
for {
|
|
select {
|
|
case item := <-n.items:
|
|
pending = append(pending, item)
|
|
if flush == nil {
|
|
flush = time.After(n.window)
|
|
}
|
|
case <-flush:
|
|
n.send(pending)
|
|
pending = []api.InboxItem{}
|
|
flush = nil
|
|
case <-n.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func Wants(p api.NotificationPrefs, item api.InboxItem) bool {
|
|
switch item.Kind {
|
|
case api.Question:
|
|
if item.Blocking {
|
|
return p.BlockingQuestions
|
|
}
|
|
return p.Questions
|
|
case api.Report:
|
|
return p.Reports
|
|
case api.SessionError:
|
|
return p.Errors
|
|
case api.RateLimited:
|
|
return p.RateLimits
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (n *Notifier) send(items []api.InboxItem) {
|
|
ctx := context.Background()
|
|
targets, err := n.store.PushTargets(ctx)
|
|
if err != nil {
|
|
slog.Error("push: list subscriptions", "err", err)
|
|
return
|
|
}
|
|
|
|
titles := map[string]string{}
|
|
for _, item := range items {
|
|
if _, ok := titles[item.TaskId]; ok {
|
|
continue
|
|
}
|
|
t, err := n.store.GetTask(ctx, item.TaskId)
|
|
if err != nil {
|
|
slog.Error("push: get task", "task", item.TaskId, "err", err)
|
|
return
|
|
}
|
|
titles[item.TaskId] = t.Title
|
|
}
|
|
|
|
for _, target := range targets {
|
|
wanted := []api.InboxItem{}
|
|
for _, item := range items {
|
|
if Wants(target.Prefs, item) {
|
|
wanted = append(wanted, item)
|
|
}
|
|
}
|
|
if len(wanted) == 0 {
|
|
continue
|
|
}
|
|
n.deliver(ctx, target.Subscription, Compose(wanted, titles), urgency(wanted))
|
|
}
|
|
}
|
|
|
|
func urgency(items []api.InboxItem) webpush.Urgency {
|
|
for _, item := range items {
|
|
if item.Blocking {
|
|
return webpush.UrgencyHigh
|
|
}
|
|
}
|
|
return webpush.UrgencyNormal
|
|
}
|
|
|
|
func Compose(items []api.InboxItem, titles map[string]string) Message {
|
|
first := items[0]
|
|
url := "/#/task/" + first.TaskId
|
|
if len(items) == 1 {
|
|
return Message{Title: "byre · " + titles[first.TaskId], Body: itemText(first), URL: url}
|
|
}
|
|
|
|
lines := []string{}
|
|
for _, item := range items {
|
|
lines = append(lines, titles[item.TaskId]+": "+itemText(item))
|
|
}
|
|
return Message{Title: fmt.Sprintf("byre · %d new inbox items", len(items)), Body: strings.Join(lines, "\n"), URL: url}
|
|
}
|
|
|
|
func itemText(item api.InboxItem) string {
|
|
text := ""
|
|
switch item.Kind {
|
|
case api.Question:
|
|
text, _ = item.Payload["question"].(string)
|
|
case api.Report:
|
|
text, _ = item.Payload["text"].(string)
|
|
case api.SessionError:
|
|
e, _ := item.Payload["error"].(string)
|
|
text = "Error: " + e
|
|
case api.RateLimited:
|
|
text = "Rate limited"
|
|
}
|
|
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
|
|
runes := []rune(text)
|
|
if len(runes) > 200 {
|
|
text = string(runes[:199]) + "…"
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (n *Notifier) deliver(ctx context.Context, sub store.PushSubscription, msg Message, u webpush.Urgency) {
|
|
payload, err := json.Marshal(msg)
|
|
if err != nil {
|
|
slog.Error("push: encode message", "err", err)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
|
Endpoint: sub.Endpoint,
|
|
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
|
|
}, &webpush.Options{
|
|
Subscriber: sub.Subscriber,
|
|
TTL: 24 * 60 * 60,
|
|
Urgency: u,
|
|
VAPIDPublicKey: n.keys.Public,
|
|
VAPIDPrivateKey: n.keys.Private,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("push: send", "user", sub.UserLogin, "err", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
|
slog.Info("push: subscription expired", "user", sub.UserLogin, "status", resp.StatusCode)
|
|
err = n.store.RemovePushSubscription(ctx, sub.Endpoint, sub.UserLogin, "byre")
|
|
if err != nil {
|
|
slog.Error("push: remove subscription", "err", err)
|
|
}
|
|
return
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
slog.Warn("push: rejected", "user", sub.UserLogin, "status", resp.StatusCode, "body", string(body))
|
|
}
|
|
}
|