// Package notify is a thin client for a ntfy server: publish // notifications and poll past ones, so agents can both alert humans // and check what the infrastructure has been complaining about. package notify import ( "bufio" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Message is one ntfy message as returned by the /json poll endpoint. type Message struct { ID string `json:"id"` Time int64 `json:"time"` Event string `json:"event"` Topic string `json:"topic"` Title string `json:"title"` Message string `json:"message"` Priority int `json:"priority"` Tags []string `json:"tags"` } // Client talks to one ntfy server. type Client struct { Server string // e.g. https://ntfy.brasse-pc.eu Token string // optional bearer token HTTP *http.Client } func New(server, token string) *Client { return &Client{ Server: strings.TrimRight(server, "/"), Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) auth(req *http.Request) { if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } } // ValidPriority reports whether p is a priority ntfy accepts. func ValidPriority(p string) bool { switch p { case "", "1", "2", "3", "4", "5", "min", "low", "default", "high", "max", "urgent": return true } return false } // Send publishes a message to a topic. func (c *Client) Send(topic, title, msg, priority string, tags []string) error { if topic == "" { return fmt.Errorf("no topic given (flag --topic or default_topic in the config)") } if msg == "" { return fmt.Errorf("empty message") } if !ValidPriority(priority) { return fmt.Errorf("invalid priority %q (use min|low|default|high|urgent or 1-5)", priority) } req, err := http.NewRequest("POST", c.Server+"/"+url.PathEscape(topic), strings.NewReader(msg)) if err != nil { return err } c.auth(req) if title != "" { req.Header.Set("Title", title) } if priority != "" { req.Header.Set("Priority", priority) } if len(tags) > 0 { req.Header.Set("Tags", strings.Join(tags, ",")) } resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { return fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body))) } return nil } // Read polls past messages from a topic. since accepts ntfy's formats: // a duration ("10m", "2h"), a unix timestamp, a message id, or "all". func (c *Client) Read(topic, since string, limit int) ([]Message, error) { if topic == "" { return nil, fmt.Errorf("no topic given (flag --topic or default_topic in the config)") } if since == "" { since = "all" } u := fmt.Sprintf("%s/%s/json?poll=1&since=%s", c.Server, url.PathEscape(topic), url.QueryEscape(since)) req, err := http.NewRequest("GET", u, nil) if err != nil { return nil, err } c.auth(req) resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("server answered %s: %s", resp.Status, strings.TrimSpace(string(body))) } var out []Message sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } var m Message if err := json.Unmarshal([]byte(line), &m); err != nil { continue // tolerate junk lines; poll output is one JSON object per line } if m.Event != "message" { continue } out = append(out, m) } if err := sc.Err(); err != nil { return nil, err } if limit > 0 && len(out) > limit { out = out[len(out)-limit:] // keep the newest } return out, nil } // Format renders a message as one stable, greppable line. func Format(m Message) string { ts := time.Unix(m.Time, 0).Format("2006-01-02 15:04:05") prio := "" switch { case m.Priority >= 4: prio = " [high]" case m.Priority > 0 && m.Priority <= 2: prio = " [low]" } title := "" if m.Title != "" { title = " (" + m.Title + ")" } tags := "" if len(m.Tags) > 0 { tags = " #" + strings.Join(m.Tags, " #") } return fmt.Sprintf("%s%s%s %s%s", ts, prio, title, strings.ReplaceAll(m.Message, "\n", " ⏎ "), tags) }