Files
lazyntfy/internal/ntfy/client.go
T

179 lines
4.3 KiB
Go
Raw Normal View History

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
}