// Package notify sends short messages about things that happened without // the user (new songs to review, automatic links and cleanups) to services // such as Discord or Pushover. package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "github.com/cdnutter/playlink/internal/store" ) // Event kinds. const ( EventReview = "review" // a scan found songs that newly need review EventLinked = "linked" // identical files were linked automatically EventCleanup = "cleanup" // an automatic cleanup removed empty folders EventTest = "test" ) // Target types. const ( TypeDiscord = "discord" TypePushover = "pushover" TypeNtfy = "ntfy" TypeGotify = "gotify" TypeWebhook = "webhook" ) // Target is one place messages are sent. Which fields are used depends on // the type: Discord and webhooks need URL; Pushover needs Token (the app // token) and User (the user key); ntfy needs URL (the topic URL) and takes // an optional access Token; Gotify needs URL (the server) and Token. type Target struct { ID string `json:"id"` Type string `json:"type"` Name string `json:"name"` Enabled bool `json:"enabled"` URL string `json:"url"` Token string `json:"token"` User string `json:"user"` } // Config is the global notification setup. type Config struct { OnReview bool `json:"on_review"` OnLinked bool `json:"on_linked"` OnCleanup bool `json:"on_cleanup"` BaseURL string `json:"base_url"` // optional: where Playlink is reachable, for links in messages Targets []Target `json:"targets"` } // Event is one thing worth telling the user about. type Event struct { Kind string PairID int64 Pair string Title string Message string Path string // page to open, relative to the app (e.g. "#/pair/1/review") } const settingKey = "notifications" const maxTargets = 10 var pushoverURL = "https://api.pushover.net/1/messages.json" // replaced in tests type Notifier struct { st *store.Store log *slog.Logger client *http.Client } func New(st *store.Store, log *slog.Logger) *Notifier { return &Notifier{st: st, log: log, client: &http.Client{Timeout: 15 * time.Second}} } // Config returns the saved configuration, with every event on by default. func (n *Notifier) Config(ctx context.Context) Config { c := Config{OnReview: true, OnLinked: true, OnCleanup: true} if raw := n.st.Setting(ctx, settingKey, ""); raw != "" { _ = json.Unmarshal([]byte(raw), &c) } if c.Targets == nil { c.Targets = []Target{} } return c } // SetConfig validates and saves the configuration. func (n *Notifier) SetConfig(ctx context.Context, c Config) error { if err := c.validate(); err != nil { return err } raw, err := json.Marshal(c) if err != nil { return err } return n.st.SetSetting(ctx, settingKey, string(raw)) } func (c *Config) validate() error { if len(c.Targets) > maxTargets { return fmt.Errorf("at most %d notification targets", maxTargets) } c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") if c.BaseURL != "" { if err := httpURL(c.BaseURL); err != nil { return fmt.Errorf("Playlink address: %w", err) } } if c.Targets == nil { c.Targets = []Target{} } for i := range c.Targets { if err := c.Targets[i].validate(); err != nil { name := c.Targets[i].Name if name == "" { name = c.Targets[i].Type } return fmt.Errorf("%s: %w", name, err) } } return nil } func (t *Target) validate() error { t.Name = strings.TrimSpace(t.Name) t.URL = strings.TrimSpace(t.URL) t.Token = strings.TrimSpace(t.Token) t.User = strings.TrimSpace(t.User) switch t.Type { case TypeDiscord, TypeWebhook, TypeNtfy: return httpURL(t.URL) case TypeGotify: if t.Token == "" { return errors.New("an application token is required") } return httpURL(t.URL) case TypePushover: if t.Token == "" || t.User == "" { return errors.New("an application token and a user key are required") } return nil } return fmt.Errorf("unknown type %q", t.Type) } func httpURL(s string) error { u, err := url.Parse(s) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return errors.New("enter a full http:// or https:// address") } return nil } // Notify sends an event to every enabled target, in the background, if // that kind of event is turned on. Failures are logged, never returned: // a notification must not hold up or fail the work it reports on. func (n *Notifier) Notify(ctx context.Context, ev Event) { c := n.Config(ctx) switch ev.Kind { case EventReview: if !c.OnReview { return } case EventLinked: if !c.OnLinked { return } case EventCleanup: if !c.OnCleanup { return } } for _, t := range c.Targets { if !t.Enabled { continue } go func(t Target) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := n.send(ctx, t, c.BaseURL, ev); err != nil { n.log.Warn("notification not sent", "target", t.Name, "type", t.Type, "event", ev.Kind, "err", err) } }(t) } } // Test sends a test message to one target and reports the result. func (n *Notifier) Test(ctx context.Context, t Target, baseURL string) error { if err := t.validate(); err != nil { return err } return n.send(ctx, t, strings.TrimRight(strings.TrimSpace(baseURL), "/"), Event{ Kind: EventTest, Title: "Playlink test notification", Message: "Notifications from Playlink will arrive here.", }) } func (n *Notifier) send(ctx context.Context, t Target, base string, ev Event) error { link := "" if base != "" { link = base + "/" if ev.Path != "" { link += ev.Path } } title := ev.Title if ev.Pair != "" { title = ev.Pair + ": " + ev.Title } var req *http.Request var err error switch t.Type { case TypeDiscord: embed := map[string]any{"title": title, "description": ev.Message, "color": 0x8b5cf6} if link != "" { embed["url"] = link } req, err = jsonRequest(ctx, t.URL, map[string]any{"username": "Playlink", "embeds": []any{embed}}) case TypePushover: form := url.Values{"token": {t.Token}, "user": {t.User}, "title": {title}, "message": {ev.Message}} if link != "" { form.Set("url", link) form.Set("url_title", "Open Playlink") } req, err = http.NewRequestWithContext(ctx, http.MethodPost, pushoverURL, strings.NewReader(form.Encode())) if err == nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } case TypeNtfy: req, err = http.NewRequestWithContext(ctx, http.MethodPost, t.URL, strings.NewReader(ev.Message)) if err == nil { req.Header.Set("Title", title) req.Header.Set("Tags", "musical_note") if link != "" { req.Header.Set("Click", link) } if t.Token != "" { req.Header.Set("Authorization", "Bearer "+t.Token) } } case TypeGotify: body := map[string]any{"title": title, "message": ev.Message, "priority": 5} if link != "" { body["extras"] = map[string]any{"client::notification": map[string]any{"click": map[string]string{"url": link}}} } req, err = jsonRequest(ctx, strings.TrimRight(t.URL, "/")+"/message", body) if err == nil { req.Header.Set("X-Gotify-Key", t.Token) } case TypeWebhook: req, err = jsonRequest(ctx, t.URL, map[string]any{ "event": ev.Kind, "pair_id": ev.PairID, "pair": ev.Pair, "title": ev.Title, "message": ev.Message, "url": link, }) default: return fmt.Errorf("unknown type %q", t.Type) } if err != nil { return err } req.Header.Set("User-Agent", "Playlink") res, err := n.client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 300 { msg, _ := io.ReadAll(io.LimitReader(res.Body, 300)) return fmt.Errorf("%s answered %s: %s", t.Type, res.Status, strings.TrimSpace(string(msg))) } return nil } func jsonRequest(ctx context.Context, u string, body any) (*http.Request, error) { raw, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return req, nil }