diff --git a/client/client.go b/client/client.go index 3b83f28b..0103a1da 100644 --- a/client/client.go +++ b/client/client.go @@ -1,3 +1,4 @@ +// Package client provides a ntfy client to publish and subscribe to topics package client import ( @@ -12,12 +13,14 @@ import ( "time" ) +// Event type constants const ( MessageEvent = "message" KeepaliveEvent = "keepalive" OpenEvent = "open" ) +// Client is the ntfy client that can be used to publish and subscribe to ntfy topics type Client struct { Messages chan *Message config *Config @@ -25,6 +28,7 @@ type Client struct { mu sync.Mutex } +// Message is a struct that represents a ntfy message type Message struct { ID string Event string @@ -42,6 +46,7 @@ type subscription struct { cancel context.CancelFunc } +// New creates a new Client using a given Config func New(config *Config) *Client { return &Client{ Messages: make(chan *Message), @@ -50,6 +55,14 @@ func New(config *Config) *Client { } } +// Publish sends a message to a specific topic, optionally using options. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, +// WithNoFirebase, and the generic WithHeader. func (c *Client) Publish(topic, message string, options ...PublishOption) error { topicURL := c.expandTopicURL(topic) req, _ := http.NewRequest("POST", topicURL, strings.NewReader(message)) @@ -68,6 +81,15 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) error return err } +// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for +// messages and does not subscribe to messages that arrive after this call. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// By default, all messages will be returned, but you can change this behavior using a SubscribeOption. +// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { ctx := context.Background() messages := make([]*Message, 0) @@ -85,6 +107,22 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err return messages, <-errChan } +// Subscribe subscribes to a topic to listen for newly incoming messages. The method starts a connection in the +// background and returns new messages via the Messages channel. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). +// +// By default, only new messages will be returned, but you can change this behavior using a SubscribeOption. +// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. +// +// Example: +// c := client.New(client.NewConfig()) +// c.Subscribe("mytopic") +// for m := range c.Messages { +// fmt.Printf("New message: %s", m.Message) +// } func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { c.mu.Lock() defer c.mu.Unlock() @@ -98,6 +136,11 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { return topicURL } +// Unsubscribe unsubscribes from a topic that has been previously subscribed with Subscribe. +// +// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// +// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the +// config (e.g. mytopic -> https://ntfy.sh/mytopic). func (c *Client) Unsubscribe(topic string) { c.mu.Lock() defer c.mu.Unlock() @@ -107,7 +150,6 @@ func (c *Client) Unsubscribe(topic string) { return } sub.cancel() - return } func (c *Client) expandTopicURL(topic string) string { @@ -128,7 +170,7 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR case <-ctx.Done(): log.Printf("Connection to %s exited", topicURL) return - case <-time.After(5 * time.Second): + case <-time.After(10 * time.Second): // TODO Add incremental backoff } } } diff --git a/client/config.go b/client/config.go index 739174ba..1b43fa1c 100644 --- a/client/config.go +++ b/client/config.go @@ -1,9 +1,11 @@ package client const ( + // DefaultBaseURL is the base URL used to expand short topic names DefaultBaseURL = "https://ntfy.sh" ) +// Config is the config struct for a Client type Config struct { DefaultHost string Subscribe []struct { @@ -12,6 +14,7 @@ type Config struct { } } +// NewConfig creates a new Config struct for a Client func NewConfig() *Config { return &Config{ DefaultHost: DefaultBaseURL, diff --git a/client/options.go b/client/options.go index 62f79d0e..ee8cce5a 100644 --- a/client/options.go +++ b/client/options.go @@ -1,49 +1,94 @@ package client import ( + "fmt" "net/http" + "strings" + "time" ) -type RequestOption func(r *http.Request) error +// RequestOption is a generic request option that can be added to Client calls +type RequestOption = func(r *http.Request) error + +// PublishOption is an option that can be passed to the Client.Publish call type PublishOption = RequestOption + +// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call type SubscribeOption = RequestOption +// WithTitle adds a title to a message func WithTitle(title string) PublishOption { return WithHeader("X-Title", title) } +// WithPriority adds a priority to a message. The priority can be either a number (1=min, 5=max), +// or the corresponding names (see util.ParsePriority). func WithPriority(priority string) PublishOption { return WithHeader("X-Priority", priority) } -func WithTags(tags string) PublishOption { +// WithTagsList adds a list of tags to a message. The tags parameter must be a comma-separated list +// of tags. To use a slice, use WithTags instead +func WithTagsList(tags string) PublishOption { return WithHeader("X-Tags", tags) } +// WithTags adds a list of a tags to a message +func WithTags(tags []string) PublishOption { + return WithTagsList(strings.Join(tags, ",")) +} + +// WithDelay instructs the server to send the message at a later date. The delay parameter can be a +// Unix timestamp, a duration string or a natural langage string. See https://ntfy.sh/docs/publish/#scheduled-delivery +// for details. func WithDelay(delay string) PublishOption { return WithHeader("X-Delay", delay) } +// WithNoCache instructs the server not to cache the message server-side func WithNoCache() PublishOption { return WithHeader("X-Cache", "no") } +// WithNoFirebase instructs the server not to forward the message to Firebase func WithNoFirebase() PublishOption { return WithHeader("X-Firebase", "no") } +// WithSince limits the number of messages returned from the server. The parameter since can be a Unix +// timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll). func WithSince(since string) SubscribeOption { return WithQueryParam("since", since) } +// WithSinceAll instructs the server to return all messages for the given topic from the server +func WithSinceAll() SubscribeOption { + return WithSince("all") +} + +// WithSinceDuration instructs the server to return all messages since the given duration ago +func WithSinceDuration(since time.Duration) SubscribeOption { + return WithSinceUnixTime(time.Now().Add(-1 * since).Unix()) +} + +// WithSinceUnixTime instructs the server to return only messages newer or equal to the given timestamp +func WithSinceUnixTime(since int64) SubscribeOption { + return WithSince(fmt.Sprintf("%d", since)) +} + +// WithPoll instructs the server to close the connection after messages have been returned. Don't use this option +// directly. Use Client.Poll instead. func WithPoll() SubscribeOption { return WithQueryParam("poll", "1") } +// WithScheduled instructs the server to also return messages that have not been sent yet, i.e. delayed/scheduled +// messages (see WithDelay). The messages will have a future date. func WithScheduled() SubscribeOption { return WithQueryParam("scheduled", "1") } +// WithHeader is a generic option to add headers to a request func WithHeader(header, value string) RequestOption { return func(r *http.Request) error { if value != "" { @@ -53,6 +98,7 @@ func WithHeader(header, value string) RequestOption { } } +// WithQueryParam is a generic option to add query parameters to a request func WithQueryParam(param, value string) RequestOption { return func(r *http.Request) error { if value != "" { diff --git a/cmd/publish.go b/cmd/publish.go index 2328328f..4d64a347 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -59,7 +59,7 @@ func execPublish(c *cli.Context) error { options = append(options, client.WithPriority(priority)) } if tags != "" { - options = append(options, client.WithTags(tags)) + options = append(options, client.WithTagsList(tags)) } if delay != "" { options = append(options, client.WithDelay(delay)) diff --git a/scripts/postrm.sh b/scripts/postrm.sh index 696e8a17..f34f6534 100755 --- a/scripts/postrm.sh +++ b/scripts/postrm.sh @@ -4,7 +4,7 @@ set -e # Delete the config if package is purged if [ "$1" = "purge" ]; then id ntfy >/dev/null 2>&1 && userdel ntfy - rm -f /etc/ntfy/server.yml + rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml rmdir /etc/ntfy || true fi diff --git a/server/config.go b/server/config.go index fac1658b..f44344f1 100644 --- a/server/config.go +++ b/server/config.go @@ -51,7 +51,7 @@ type Config struct { BehindProxy bool } -// New instantiates a default new config +// NewConfig instantiates a default new server config func NewConfig(listenHTTP string) *Config { return &Config{ ListenHTTP: listenHTTP, diff --git a/server/server_test.go b/server/server_test.go index d06377ad..3abeb472 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "github.com/stretchr/testify/require" - "heckel.io/ntfy/config" "net/http" "net/http/httptest" "os" @@ -393,13 +392,13 @@ func TestServer_PublishFirebase(t *testing.T) { time.Sleep(500 * time.Millisecond) // Time for sends } -func newTestConfig(t *testing.T) *config.Config { - conf := config.New(":80") +func newTestConfig(t *testing.T) *Config { + conf := NewConfig(":80") conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") return conf } -func newTestServer(t *testing.T, config *config.Config) *Server { +func newTestServer(t *testing.T, config *Config) *Server { server, err := New(config) if err != nil { t.Fatal(err) diff --git a/util/util.go b/util/util.go index 8acd0032..010c0f58 100644 --- a/util/util.go +++ b/util/util.go @@ -80,8 +80,9 @@ func DurationToHuman(d time.Duration) (str string) { return } +// ParsePriority parses a priority string into its equivalent integer value func ParsePriority(priority string) (int, error) { - switch strings.ToLower(priority) { + switch strings.TrimSpace(strings.ToLower(priority)) { case "": return 0, nil case "1", "min": diff --git a/util/util_test.go b/util/util_test.go index d5ebcad3..06c2af9b 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -63,3 +63,21 @@ func TestExpandHome_WithTilde(t *testing.T) { func TestExpandHome_NoTilde(t *testing.T) { require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path")) } + +func TestParsePriority(t *testing.T) { + priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"} + expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5} + for i, priority := range priorities { + actual, err := ParsePriority(priority) + require.Nil(t, err) + require.Equal(t, expected[i], actual) + } +} + +func TestParsePriority_Invalid(t *testing.T) { + priorities := []string{"-1", "6", "aa", "-"} + for _, priority := range priorities { + _, err := ParsePriority(priority) + require.Equal(t, errInvalidPriority, err) + } +}