notify.go

 1package runner
 2
 3import (
 4	"context"
 5	"log/slog"
 6	"net/http"
 7	"strings"
 8	"time"
 9)
10
11// Notification is a gate waiting for a person, or a finished run.
12type Notification struct {
13	Kind    string // gate | finished
14	Run     Run
15	Status  string
16	Title   string
17	Message string
18}
19
20// Ntfy returns a notifier posting to an ntfy topic URL (e.g.
21// https://ntfy.sh/<topic>). Gates and failures are sent with high priority;
22// successes only if notifySuccess. appview, if set, is linked from each
23// notification as a view action (no tokens in notifications).
24func Ntfy(topicURL, token, appview string, notifySuccess bool, logger *slog.Logger) func(Notification) {
25	return func(n Notification) {
26		if n.Kind == "finished" && n.Status == "success" && !notifySuccess {
27			return
28		}
29		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
30		defer cancel()
31		req, err := http.NewRequestWithContext(ctx, http.MethodPost, topicURL, strings.NewReader(n.Message))
32		if err != nil {
33			logger.Error("ntfy", "err", err)
34			return
35		}
36		req.Header.Set("Title", n.Title)
37		switch {
38		case n.Kind == "gate":
39			req.Header.Set("Priority", "high")
40			req.Header.Set("Tags", "raised_hand")
41		case n.Status == "success":
42			req.Header.Set("Tags", "white_check_mark")
43		default:
44			req.Header.Set("Priority", "high")
45			req.Header.Set("Tags", "x")
46		}
47		if appview != "" {
48			u := strings.TrimSuffix(appview, "/") + "/runs/" + n.Run.ID
49			req.Header.Set("Click", u)
50			req.Header.Set("Actions", "view, Open, "+u)
51		}
52		if token != "" {
53			req.Header.Set("Authorization", "Bearer "+token)
54		}
55		resp, err := http.DefaultClient.Do(req)
56		if err != nil {
57			logger.Error("ntfy", "err", err)
58			return
59		}
60		resp.Body.Close()
61		if resp.StatusCode >= 300 {
62			logger.Error("ntfy", "status", resp.Status)
63		}
64	}
65}