package ntfy import ( "context" "fmt" "io" "net" "net/http" "strconv" "strings" "time" "lazyntfy/internal/config" ) const userAgent = "lazyntfy/0.1.0" // Client talks to an ntfy server over HTTP. type Client struct { HTTP *http.Client } // NewClient returns a client whose timeout covers the handshake, not the // long-lived response body. func NewClient() *Client { return &Client{HTTP: &http.Client{Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, IdleConnTimeout: 90 * time.Second, ExpectContinueTimeout: 1 * time.Second, }}} } func (c *Client) http() *http.Client { if c != nil && c.HTTP != nil { return c.HTTP } return http.DefaultClient } // PublishRequest is a plain-text ntfy publish. type PublishRequest struct { Title string Message string Priority int Tags []string } // StreamMeta is returned once response headers have been read. type StreamMeta struct { Truncated bool } // OpenStream starts a JSON subscription. The caller must close the body. func (c *Client) OpenStream(ctx context.Context, endpoint string, auth config.Auth) (*http.Response, StreamMeta, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, StreamMeta{}, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/x-ndjson") auth.Apply(req) resp, err := c.http().Do(req) if err != nil { return nil, StreamMeta{}, err } if resp.StatusCode != http.StatusOK { defer resp.Body.Close() return nil, StreamMeta{}, httpStatusError(resp) } return resp, StreamMeta{Truncated: resp.Header.Get("X-Messages-Truncated") == "1"}, nil } // Publish sends a message to one topic. func (c *Client) Publish(ctx context.Context, origin, name string, auth config.Auth, msg PublishRequest) error { if strings.TrimSpace(msg.Message) == "" { return fmt.Errorf("message is empty") } req, err := http.NewRequestWithContext(ctx, http.MethodPost, PublishURL(origin, name), strings.NewReader(msg.Message)) if err != nil { return err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Content-Type", "text/plain; charset=utf-8") if msg.Title != "" { req.Header.Set("Title", msg.Title) } if msg.Priority > 0 { req.Header.Set("Priority", priorityHeader(msg.Priority)) } if len(msg.Tags) > 0 { req.Header.Set("Tags", strings.Join(msg.Tags, ",")) } auth.Apply(req) resp, err := c.http().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return httpStatusError(resp) } _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return nil } func httpStatusError(resp *http.Response) error { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return fmt.Errorf("HTTP %d: not authorized for this topic", resp.StatusCode) } body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) msg := strings.TrimSpace(string(body)) msg = strings.ReplaceAll(msg, "\n", " ") if msg == "" { return fmt.Errorf("HTTP %s", resp.Status) } return fmt.Errorf("HTTP %s: %s", resp.Status, msg) } func priorityHeader(p int) string { switch p { case 1: return "min" case 2: return "low" case 3: return "default" case 4: return "high" case 5: return "max" default: return strconv.Itoa(p) } } // ParsePriority accepts 1-5 or the ntfy names min, low, default, high, and max. func ParsePriority(s string) (int, error) { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { return 3, nil } switch s { case "min", "1": return 1, nil case "low", "2": return 2, nil case "default", "def", "3": return 3, nil case "high", "4": return 4, nil case "max", "urgent", "5": return 5, nil default: return 0, fmt.Errorf("priority must be 1-5 or min/low/default/high/max") } } // ParseTags splits a comma-separated tag list. func ParseTags(s string) []string { if strings.TrimSpace(s) == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }