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}