Skip to content

Commit

Permalink
cleanup(pushbullet): TLC (#180)
Browse files Browse the repository at this point in the history
- increase test coverage
- implement missing parts of the Push API
- actually handle errors (and return API errors to consumer)
- use common service helpers / JSON Client
- title is now actually settable the same way as all other service props
  • Loading branch information
piksel committed Jul 4, 2021
1 parent a86ddc7 commit dbe0e3b
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 147 deletions.
98 changes: 33 additions & 65 deletions pkg/services/pushbullet/pushbullet.go
Original file line number Diff line number Diff line change
@@ -1,104 +1,72 @@
package pushbullet

import (
"bytes"
"fmt"
"net/http"
"net/url"
"regexp"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/shoutrrr/pkg/util/jsonclient"
"net/url"
)

const (
pushesEndpoint = "https://api.pushbullet.com/v2/pushes"
)

// Service providing Pushbullet as a notification service
type Service struct {
standard.Standard
client jsonclient.Client
config *Config
pkr format.PropKeyResolver
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.Logger.SetLogger(logger)

service.config = &Config{}
if err := service.config.SetURL(configURL); err != nil {
service.pkr = format.NewPropKeyResolver(service.config)
if err := service.config.setURL(&service.pkr, configURL); err != nil {
return err
}

service.client = jsonclient.NewClient()
service.client.Headers().Set("Access-Token", service.config.Token)

return nil
}

// Send ...
// Send a push notification via Pushbullet
func (service *Service) Send(message string, params *types.Params) error {
config := service.config
config := *service.config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return err
}

for _, target := range config.Targets {
if err := doSend(config, target, message, params); err != nil {
if err := doSend(&config, target, message, service.client); err != nil {
return err
}
}
return nil
}

func getTitle(params *types.Params) string {
title := "Shoutrrr notification"
if params != nil {
valParams := *params
title, ok := valParams["title"]
if !ok {
return title
}
}
return title
}
func doSend(config *Config, target string, message string, client jsonclient.Client) error {

func doSend(config *Config, target string, message string, params *types.Params) error {
targetType, err := getTargetType(target)
if err != nil {
return err
}
push := NewNotePush(message, config.Title)
push.SetTarget(target)

apiURL := serviceURL
json, _ := CreateJSONPayload(target, targetType, config, message, params)
client := &http.Client{}
req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(json))
req.Header.Add("Access-Token", config.Token)
req.Header.Add("Content-Type", "application/json")

res, err := client.Do(req)

if res.StatusCode != http.StatusOK {
return fmt.Errorf("failed to send notification to service, response status code %s", res.Status)
response := PushResponse{}
if err := client.Post(pushesEndpoint, push, &response); err != nil {
errorResponse := ErrorResponse{}
if client.ErrorResponse(err, &errorResponse) {
return fmt.Errorf("API error: %v", errorResponse.Error.Message)
}
return fmt.Errorf("failed to push: %v", err)
}

if err != nil {
return fmt.Errorf("error occurred while posting to pushbullet: %s", err.Error())
}
// TODO: Look at response fields?

return nil
}

func getTargetType(target string) (TargetType, error) {
matchesEmail, err := regexp.MatchString(`.*@.*\..*`, target)

if matchesEmail && err == nil {
return EmailTarget, nil
}

if len(target) > 0 && string(target[0]) == "#" {
return ChannelTarget, nil
}

return DeviceTarget, nil
}

// TargetType ...
type TargetType int

const (
// EmailTarget ...
EmailTarget TargetType = 1
// ChannelTarget ...
ChannelTarget TargetType = 2
// DeviceTarget ...
DeviceTarget TargetType = 3
)
63 changes: 38 additions & 25 deletions pkg/services/pushbullet/pushbullet_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package pushbullet
import (
"errors"
"fmt"
"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"
"net/url"
"strings"

Expand All @@ -14,62 +16,73 @@ type Config struct {
standard.EnumlessConfig
Targets []string `url:"path"`
Token string `url:"host"`
Title string `key:"title" default:"Shoutrrr notification"`
}

// GetURL returns a URL representation of it's current field values
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}

// SetURL updates a ServiceConfig from a URL representation of it's field values
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}

func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
Host: config.Token,
Path: "/" + strings.Join(config.Targets, "/"),
Scheme: Scheme,
ForceQuery: false,
RawQuery: format.BuildQuery(resolver),
}
}

// SetURL updates a ServiceConfig from a URL representation of it's field values
func (config *Config) SetURL(url *url.URL) error {
splitBySlash := func(c rune) bool {
return c == '/'
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
path := url.Path

if len(path) > 0 && path[0] == '/' {
// Remove initial slash to skip empty first target
path = path[1:]
}

path := strings.FieldsFunc(url.Path, splitBySlash)
if url.Fragment != "" {
path = append(path, fmt.Sprintf("#%s", url.Fragment))
}
if len(path) == 0 {
path = []string{""}
path += fmt.Sprintf("/#%s", url.Fragment)
}

config.Token = url.Host
config.Targets = path[0:]
targets := strings.Split(path, "/")

if err := validateToken(config.Token); err != nil {
token := url.Hostname()
if err := validateToken(token); err != nil {
return err
}

return nil
}
config.Token = token
config.Targets = targets

func validateToken(token string) error {
if err := tokenHasCorrectSize(token); err != nil {
return err
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return err
}
}

return nil
}

func tokenHasCorrectSize(token string) error {
func validateToken(token string) error {
if len(token) != 34 {
return errors.New(string(TokenIncorrectSize))
return ErrorTokenIncorrectSize
}
return nil
}

//ErrorMessage for error events within the pushbullet service
type ErrorMessage string

const (
serviceURL = "https://api.pushbullet.com/v2/pushes"
//Scheme is the scheme part of the service configuration URL
Scheme = "pushbullet"
//TokenIncorrectSize for the serviceURL
TokenIncorrectSize ErrorMessage = "Token has incorrect size"
)

// ErrorTokenIncorrectSize is the error returned when the token size is incorrect
var ErrorTokenIncorrectSize = errors.New("token has incorrect size")
81 changes: 47 additions & 34 deletions pkg/services/pushbullet/pushbullet_json.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package pushbullet

import (
"encoding/json"

"github.com/containrrr/shoutrrr/pkg/types"
"regexp"
)

// JSON used within the Slack service
type JSON struct {
// PushRequest ...
type PushRequest struct {
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Expand All @@ -17,39 +15,54 @@ type JSON struct {
DeviceIden string `json:"device_iden"`
}

// CreateJSONPayload compatible with the slack webhook api
func CreateJSONPayload(target string, targetType TargetType, config *Config, message string, params *types.Params) ([]byte, error) {
baseMessage := JSON{
Type: "note",
Title: getTitle(params),
Body: message,
}

switch targetType {
case EmailTarget:
return CreateEmailPayload(config, target, baseMessage)
case ChannelTarget:
return CreateChannelPayload(config, target, baseMessage)
case DeviceTarget:
return CreateDevicePayload(config, target, baseMessage)
}
return json.Marshal(baseMessage)
type PushResponse struct {
Active bool `json:"active"`
Body string `json:"body"`
Created float64 `json:"created"`
Direction string `json:"direction"`
Dismissed bool `json:"dismissed"`
Iden string `json:"iden"`
Modified float64 `json:"modified"`
ReceiverEmail string `json:"receiver_email"`
ReceiverEmailNormalized string `json:"receiver_email_normalized"`
ReceiverIden string `json:"receiver_iden"`
SenderEmail string `json:"sender_email"`
SenderEmailNormalized string `json:"sender_email_normalized"`
SenderIden string `json:"sender_iden"`
SenderName string `json:"sender_name"`
Title string `json:"title"`
Type string `json:"type"`
}

//CreateChannelPayload from a base message
func CreateChannelPayload(config *Config, target string, partialPayload JSON) ([]byte, error) {
partialPayload.ChannelTag = target[1:]
return json.Marshal(partialPayload)
type ErrorResponse struct {
Error struct {
Cat string `json:"cat"`
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}

//CreateDevicePayload from a base message
func CreateDevicePayload(config *Config, target string, partialPayload JSON) ([]byte, error) {
partialPayload.DeviceIden = target
return json.Marshal(partialPayload)
var emailPattern = regexp.MustCompile(`.*@.*\..*`)

func (p *PushRequest) SetTarget(target string) {
if emailPattern.MatchString(target) {
p.Email = target
return
}

if len(target) > 0 && string(target[0]) == "#" {
p.ChannelTag = target[1:]
return
}

p.DeviceIden = target
}

//CreateEmailPayload from a base message
func CreateEmailPayload(config *Config, target string, partialPayload JSON) ([]byte, error) {
partialPayload.Email = target
return json.Marshal(partialPayload)
// NewNotePush creates a new push request
func NewNotePush(message, title string) *PushRequest {
return &PushRequest{
Type: "note",
Title: title,
Body: message,
}
}
Loading

0 comments on commit dbe0e3b

Please sign in to comment.