feat(ntfy): add the HTTP client, stream manager, and account discovery
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.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user