-
Notifications
You must be signed in to change notification settings - Fork 231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(service): Add Ntfy #469
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
# Ntfy | ||
|
||
[](https://pkg.go.dev/github.com/nikoksr/notify/service/ntfy) | ||
|
||
## Usage | ||
|
||
```go | ||
package main | ||
|
||
import ( | ||
"context" | ||
|
||
"github.com/nikoksr/notify" | ||
"github.com/nikoksr/notify/service/ntfy" | ||
) | ||
|
||
func main() { | ||
// Create a ntfy service. You can use the | ||
// `ntfy.NewWithServers` function to create a service with a custom server. | ||
//ntfyService := ntfy.NewWithServers(ntfy.DefaultServerURL) | ||
|
||
// Or use `ntfy.New` to create a service with the default server. | ||
ntfyService := ntfy.New() | ||
|
||
// Tell our notifier to use the bark service. | ||
notify.UseServices(ntfyService) | ||
|
||
content := `{ | ||
"message": "Disk space is low at 5.1 GB", | ||
"title": "Low disk space alert", | ||
"tags": ["warning","cd"], | ||
"priority": 4, | ||
"attach": "https://filesrv.lan/space.jpg", | ||
"filename": "diskspace.jpg", | ||
"click": "https://homecamera.lan/xasds1h2xsSsa/", | ||
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] | ||
}` | ||
|
||
// Send a test message. | ||
err := notify.Send( | ||
context.Background(), | ||
"pushkar", | ||
content, | ||
) | ||
|
||
if err != nil { | ||
panic(err) | ||
} | ||
} | ||
``` |
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,174 @@ | ||||||||||||||||||
package ntfy | ||||||||||||||||||
|
||||||||||||||||||
import ( | ||||||||||||||||||
"bytes" | ||||||||||||||||||
"context" | ||||||||||||||||||
"encoding/json" | ||||||||||||||||||
"fmt" | ||||||||||||||||||
"io" | ||||||||||||||||||
"net/http" | ||||||||||||||||||
"strings" | ||||||||||||||||||
"time" | ||||||||||||||||||
|
||||||||||||||||||
"github.com/pkg/errors" | ||||||||||||||||||
) | ||||||||||||||||||
|
||||||||||||||||||
// Service allow you to configure Ntfy service. | ||||||||||||||||||
type Service struct { | ||||||||||||||||||
client *http.Client | ||||||||||||||||||
serverURLs []string | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
func defaultHTTPClient() *http.Client { | ||||||||||||||||||
return &http.Client{ | ||||||||||||||||||
Timeout: 5 * time.Second, | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// DefaultServerURL is the default server to use for the Ntfy service. | ||||||||||||||||||
const DefaultServerURL = "https://ntfy.sh" | ||||||||||||||||||
|
||||||||||||||||||
// normalizeServerURL normalizes the server URL. It prefixes it with https:// if it's not already and appends a slash | ||||||||||||||||||
// if it's not already there. If the serverURL is empty, the DefaultServerURL is used. We're not validating the url here | ||||||||||||||||||
// on purpose, we leave that to the http client. | ||||||||||||||||||
func normalizeServerURL(serverURL string) string { | ||||||||||||||||||
if serverURL == "" { | ||||||||||||||||||
return DefaultServerURL | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// Normalize the url | ||||||||||||||||||
if !strings.HasPrefix(serverURL, "http") { | ||||||||||||||||||
serverURL = "https://" + serverURL | ||||||||||||||||||
} | ||||||||||||||||||
if !strings.HasSuffix(serverURL, "/") { | ||||||||||||||||||
serverURL = serverURL + "/" | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
return serverURL | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// AddReceivers adds server URLs to the list of servers to use for sending messages. | ||||||||||||||||||
func (s *Service) AddReceivers(serverURLs ...string) { | ||||||||||||||||||
for _, serverURL := range serverURLs { | ||||||||||||||||||
serverURL = normalizeServerURL(serverURL) | ||||||||||||||||||
s.serverURLs = append(s.serverURLs, serverURL) | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// NewWithServers returns a new instance of Ntfy service. You can use this service to send messages to Ntfy. You can | ||||||||||||||||||
// specify the servers to send the messages to. By default, the service will use the default server | ||||||||||||||||||
// (https://api.day.app/) if you don't specify any servers. | ||||||||||||||||||
func NewWithServers(serverURLs ...string) *Service { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @svaloumas @pushkar803 I'm torn about this one. On one hand I like the convenience this offers (we're doing the same in the notify package), but on the other hand I see two things I dislike.
|
||||||||||||||||||
s := &Service{ | ||||||||||||||||||
client: defaultHTTPClient(), | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if len(serverURLs) == 0 { | ||||||||||||||||||
serverURLs = append(serverURLs, DefaultServerURL) | ||||||||||||||||||
} | ||||||||||||||||||
Comment on lines
+66
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Length check is obsolete. An empty list will not alter the existsing serverURLs anyway.
Suggested change
|
||||||||||||||||||
|
||||||||||||||||||
// Calling service.AddReceivers() instead of directly setting the serverURLs because we want to normalize the URLs. | ||||||||||||||||||
s.AddReceivers(serverURLs...) | ||||||||||||||||||
|
||||||||||||||||||
return s | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// New returns a new instance of Ntfy service. You can use this service to send messages to Ntfy. By default, the | ||||||||||||||||||
// service will use the default server (https://ntfy.sh). | ||||||||||||||||||
func New() *Service { | ||||||||||||||||||
return NewWithServers() | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// postData is the data to send to the Ntfy server. | ||||||||||||||||||
type postData struct { | ||||||||||||||||||
Topic string `json:"topic"` | ||||||||||||||||||
nikoksr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
Message string `json:"message,omitempty"` | ||||||||||||||||||
Title string `json:"title,omitempty"` | ||||||||||||||||||
Tags []string `json:"tags,omitempty"` | ||||||||||||||||||
Priority int `json:"priority,omitempty"` | ||||||||||||||||||
Attach string `json:"attach,omitempty"` | ||||||||||||||||||
Filename string `json:"filename,omitempty"` | ||||||||||||||||||
Click string `json:"click,omitempty"` | ||||||||||||||||||
Actions []struct { | ||||||||||||||||||
Action string `json:"action"` | ||||||||||||||||||
Label string `json:"label,omitempty"` | ||||||||||||||||||
URL string `json:"url"` | ||||||||||||||||||
} `json:"actions,omitempty"` | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
func (s *Service) send(ctx context.Context, serverURL, topic, content string) (err error) { | ||||||||||||||||||
if serverURL == "" { | ||||||||||||||||||
return errors.New("server url is empty") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
bodyByte := []byte(content) | ||||||||||||||||||
|
||||||||||||||||||
isJson := json.Valid(bodyByte) | ||||||||||||||||||
if !isJson { | ||||||||||||||||||
err := errors.New("Invalid JSON Body") | ||||||||||||||||||
return err | ||||||||||||||||||
} | ||||||||||||||||||
Comment on lines
+106
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential simplification.
Suggested change
|
||||||||||||||||||
|
||||||||||||||||||
var bodyj postData | ||||||||||||||||||
if err := json.Unmarshal(bodyByte, &bodyj); err != nil { | ||||||||||||||||||
err := errors.Wrap(err, "Invalid PostData structure") | ||||||||||||||||||
return err | ||||||||||||||||||
Comment on lines
+114
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if bodyj.Topic == "" { | ||||||||||||||||||
bodyj.Topic = topic | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
messageJSON, err := json.Marshal(bodyj) | ||||||||||||||||||
if err != nil { | ||||||||||||||||||
return errors.Wrap(err, "marshal message") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// Create new request | ||||||||||||||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, bytes.NewBuffer(messageJSON)) | ||||||||||||||||||
if err != nil { | ||||||||||||||||||
return errors.Wrap(err, "create request") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
req.Header.Set("Content-Type", "application/json; charset=utf-8") | ||||||||||||||||||
|
||||||||||||||||||
// Send request | ||||||||||||||||||
resp, err := s.client.Do(req) | ||||||||||||||||||
if err != nil { | ||||||||||||||||||
return errors.Wrap(err, "send request") | ||||||||||||||||||
} | ||||||||||||||||||
defer func() { _ = resp.Body.Close() }() | ||||||||||||||||||
|
||||||||||||||||||
// Read response and verify success | ||||||||||||||||||
result, err := io.ReadAll(resp.Body) | ||||||||||||||||||
if err != nil { | ||||||||||||||||||
return errors.Wrap(err, "read response") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
if resp.StatusCode != http.StatusOK { | ||||||||||||||||||
return fmt.Errorf("Ntfy returned status code %d: %s", resp.StatusCode, string(result)) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
return nil | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// Send takes a message subject and a message content and sends them to Ntfy application. | ||||||||||||||||||
func (s *Service) Send(ctx context.Context, subject, content string) error { | ||||||||||||||||||
if s.client == nil { | ||||||||||||||||||
return errors.New("client is nil") | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
for _, serverURL := range s.serverURLs { | ||||||||||||||||||
select { | ||||||||||||||||||
case <-ctx.Done(): | ||||||||||||||||||
return ctx.Err() | ||||||||||||||||||
default: | ||||||||||||||||||
err := s.send(ctx, serverURL, subject, content) | ||||||||||||||||||
if err != nil { | ||||||||||||||||||
return errors.Wrapf(err, "failed to send message to Ntfy server %q", serverURL) | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
return nil | ||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
package ntfy | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestNtfy_New(t *testing.T) { | ||
t.Parallel() | ||
|
||
assert := require.New(t) | ||
|
||
service := New() | ||
assert.NotNil(service) | ||
} | ||
|
||
func TestNtfy_NewWithServers(t *testing.T) { | ||
t.Parallel() | ||
|
||
assert := require.New(t) | ||
|
||
service := NewWithServers() | ||
assert.NotNil(service) | ||
|
||
service = NewWithServers("xyz.com", "abc.com") | ||
assert.NotNil(service) | ||
} | ||
|
||
func TestNtfy_AddReceivers(t *testing.T) { | ||
t.Parallel() | ||
|
||
assert := require.New(t) | ||
|
||
service := New() | ||
assert.NotNil(service) | ||
|
||
rec := []string{"https://rec1.sh/", "https://rec2.sh/", "https://rec3.sh/"} | ||
expected := []string{"https://ntfy.sh/", "https://rec1.sh/", "https://rec2.sh/", "https://rec3.sh/"} | ||
service.AddReceivers(rec...) | ||
|
||
assert.Equal(service.serverURLs, expected) | ||
} | ||
|
||
func TestNtfy_send(t *testing.T) { | ||
t.Parallel() | ||
|
||
assert := require.New(t) | ||
|
||
service := New() | ||
assert.NotNil(service) | ||
|
||
service.client = defaultHTTPClient() | ||
|
||
ctx := context.Background() | ||
serverURL := DefaultServerURL | ||
topic := "pushkar" | ||
content := `{ | ||
"message": "Disk space is low at 5.1 GB", | ||
"title": "Low disk space alert", | ||
"tags": ["warning","cd"], | ||
"priority": 4, | ||
"attach": "https://filesrv.lan/space.jpg", | ||
"filename": "diskspace.jpg", | ||
"click": "https://homecamera.lan/xasds1h2xsSsa/", | ||
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] | ||
}` | ||
|
||
err := service.send(ctx, serverURL, topic, content) | ||
assert.Nil(err) | ||
|
||
err = service.send(ctx, "", topic, content) | ||
assert.NotNil(err) | ||
|
||
err = service.send(ctx, "xyz", topic, content) | ||
assert.NotNil(err) | ||
|
||
err = service.send(ctx, serverURL, topic, "sample_body") | ||
assert.NotNil(err) | ||
|
||
content = `{ | ||
"message": "Disk space is low at 5.1 GB", | ||
"title": "Low disk space alert", | ||
"priority": 4222, | ||
}` | ||
|
||
err = service.send(ctx, serverURL, topic, content) | ||
assert.NotNil(err) | ||
|
||
} | ||
|
||
func TestNtfy_Send(t *testing.T) { | ||
t.Parallel() | ||
|
||
assert := require.New(t) | ||
|
||
service := New() | ||
assert.NotNil(service) | ||
|
||
ctx := context.Background() | ||
topic := "pushkar" | ||
content := `{ | ||
"message": "Disk space is low at 5.1 GB", | ||
"title": "Low disk space alert", | ||
"tags": ["warning","cd"], | ||
"priority": 4, | ||
"attach": "https://filesrv.lan/space.jpg", | ||
"filename": "diskspace.jpg", | ||
"click": "https://homecamera.lan/xasds1h2xsSsa/", | ||
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }] | ||
}` | ||
|
||
err := service.Send(ctx, topic, content) | ||
assert.Nil(err) | ||
|
||
content = `{ | ||
"message": "Disk space is low at 5.1 GB", | ||
"title": "Low disk space alert", | ||
"priority": 4222, | ||
}` | ||
|
||
err = service.Send(ctx, topic, content) | ||
assert.NotNil(err) | ||
} | ||
Comment on lines
+46
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I'm not completely misunderstanding this, we're pinging the actual endpoint here. In that case, we can't let this go through. We're solely relying on mocked tests for all other services and should do the same here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see a need for this to be exported. We can keep the usage experience a little bit cleaner by making it non-exported.