Speak the ntfy JSON API directly: subscribe, publish, group topics that share a server and credentials, reconnect with backoff and a since cursor, and list account subscriptions, reservations, and ACL grants.
179 lines
4.3 KiB
Go
179 lines
4.3 KiB
Go
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
|
|
}
|