1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View file

@ -0,0 +1,118 @@
package pushbullet
import (
"errors"
"fmt"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient"
)
// Constants.
const (
pushesEndpoint = "https://api.pushbullet.com/v2/pushes"
)
// Static errors for push validation.
var (
ErrUnexpectedResponseType = errors.New("unexpected response type, expected note")
ErrResponseBodyMismatch = errors.New("response body mismatch")
ErrResponseTitleMismatch = errors.New("response title mismatch")
ErrPushNotActive = errors.New("push notification is not active")
)
// 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.SetLogger(logger)
service.Config = &Config{
Title: "Shoutrrr notification", // Explicitly set default
}
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
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// Send a push notification via Pushbullet.
func (service *Service) Send(message string, params *types.Params) error {
config := *service.Config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating config from params: %w", err)
}
for _, target := range config.Targets {
if err := doSend(&config, target, message, service.client); err != nil {
return err
}
}
return nil
}
// doSend sends a push notification to a specific target and validates the response.
func doSend(config *Config, target string, message string, client jsonclient.Client) error {
push := NewNotePush(message, config.Title)
push.SetTarget(target)
response := PushResponse{}
if err := client.Post(pushesEndpoint, push, &response); err != nil {
errorResponse := &ResponseError{}
if client.ErrorResponse(err, errorResponse) {
return fmt.Errorf("API error: %w", errorResponse)
}
return fmt.Errorf("failed to push: %w", err)
}
// Validate response fields
if response.Type != "note" {
return fmt.Errorf("%w: got %s", ErrUnexpectedResponseType, response.Type)
}
if response.Body != message {
return fmt.Errorf(
"%w: got %s, expected %s",
ErrResponseBodyMismatch,
response.Body,
message,
)
}
if response.Title != config.Title {
return fmt.Errorf(
"%w: got %s, expected %s",
ErrResponseTitleMismatch,
response.Title,
config.Title,
)
}
if !response.Active {
return ErrPushNotActive
}
return nil
}

View file

@ -0,0 +1,95 @@
package pushbullet
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the scheme part of the service configuration URL.
const Scheme = "pushbullet"
// ExpectedTokenLength is the required length for a valid Pushbullet token.
const ExpectedTokenLength = 34
// ErrTokenIncorrectSize indicates that the token has an incorrect size.
var ErrTokenIncorrectSize = errors.New("token has incorrect size")
// Config holds the configuration for the Pushbullet service.
type Config struct {
standard.EnumlessConfig
Targets []string `url:"path"`
Token string `url:"host"`
Title string ` default:"Shoutrrr notification" key:"title"`
}
// GetURL returns a URL representation of the Config's current field values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the Config from a URL representation of its field values.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from the Config's fields using the provided resolver.
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 the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
path := url.Path
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
if url.Fragment != "" {
path += "/#" + url.Fragment
}
targets := strings.Split(path, "/")
token := url.Hostname()
if url.String() != "pushbullet://dummy@dummy.com" {
if err := validateToken(token); err != nil {
return err
}
}
config.Token = token
config.Targets = targets
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err)
}
}
return nil
}
// validateToken checks if the token meets the expected length requirement.
func validateToken(token string) error {
if len(token) != ExpectedTokenLength {
return ErrTokenIncorrectSize
}
return nil
}

View file

@ -0,0 +1,74 @@
package pushbullet
import (
"regexp"
)
var emailPattern = regexp.MustCompile(`.*@.*\..*`)
// PushRequest ...
type PushRequest struct {
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
Email string `json:"email"`
ChannelTag string `json:"channel_tag"`
DeviceIden string `json:"device_iden"`
}
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"`
}
type ResponseError struct {
ErrorData struct {
Cat string `json:"cat"`
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
func (err *ResponseError) Error() string {
return err.ErrorData.Message
}
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
}
// NewNotePush creates a new push request.
func NewNotePush(message, title string) *PushRequest {
return &PushRequest{
Type: "note",
Title: title,
Body: message,
}
}

View file

@ -0,0 +1,248 @@
package pushbullet_test
import (
"errors"
"net/url"
"os"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet"
)
func TestPushbullet(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Pushbullet Suite")
}
var (
service *pushbullet.Service
envPushbulletURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &pushbullet.Service{}
envPushbulletURL, _ = url.Parse(os.Getenv("SHOUTRRR_PUSHBULLET_URL"))
})
)
var _ = ginkgo.Describe("the pushbullet service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
if envPushbulletURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envPushbulletURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("This is an integration test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("pushbullet"))
})
})
ginkgo.Describe("the pushbullet config", func() {
ginkgo.When("generating a config object", func() {
ginkgo.It("should set token", func() {
pushbulletURL, _ := url.Parse("pushbullet://tokentokentokentokentokentokentoke")
config := pushbullet.Config{}
err := config.SetURL(pushbulletURL)
gomega.Expect(config.Token).To(gomega.Equal("tokentokentokentokentokentokentoke"))
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set the device from path", func() {
pushbulletURL, _ := url.Parse(
"pushbullet://tokentokentokentokentokentokentoke/test",
)
config := pushbullet.Config{}
err := config.SetURL(pushbulletURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Targets).To(gomega.HaveLen(1))
gomega.Expect(config.Targets).To(gomega.ContainElements("test"))
})
ginkgo.It("should set the channel from path", func() {
pushbulletURL, _ := url.Parse(
"pushbullet://tokentokentokentokentokentokentoke/foo#bar",
)
config := pushbullet.Config{}
err := config.SetURL(pushbulletURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Targets).To(gomega.HaveLen(2))
gomega.Expect(config.Targets).To(gomega.ContainElements("foo", "#bar"))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "pushbullet://tokentokentokentokentokentokentoke/device?title=Great+News"
config := &pushbullet.Config{}
err := config.SetURL(testutils.URLMust(testURL))
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.Describe("building the payload", func() {
ginkgo.It("Email target should only populate one the correct field", func() {
push := pushbullet.PushRequest{}
push.SetTarget("iam@email.com")
gomega.Expect(push.Email).To(gomega.Equal("iam@email.com"))
gomega.Expect(push.DeviceIden).To(gomega.BeEmpty())
gomega.Expect(push.ChannelTag).To(gomega.BeEmpty())
})
ginkgo.It("Device target should only populate one the correct field", func() {
push := pushbullet.PushRequest{}
push.SetTarget("device")
gomega.Expect(push.Email).To(gomega.BeEmpty())
gomega.Expect(push.DeviceIden).To(gomega.Equal("device"))
gomega.Expect(push.ChannelTag).To(gomega.BeEmpty())
})
ginkgo.It("Channel target should only populate one the correct field", func() {
push := pushbullet.PushRequest{}
push.SetTarget("#channel")
gomega.Expect(push.Email).To(gomega.BeEmpty())
gomega.Expect(push.DeviceIden).To(gomega.BeEmpty())
gomega.Expect(push.ChannelTag).To(gomega.Equal("channel"))
})
})
ginkgo.Describe("sending the payload", func() {
var err error
targetURL := "https://api.pushbullet.com/v2/pushes"
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
response := pushbullet.PushResponse{
Type: "note",
Body: "Message",
Title: "Shoutrrr notification", // Matches default
Active: true,
}
responder, _ := httpmock.NewJsonResponder(200, &response)
httpmock.RegisterResponder("POST", targetURL, responder)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should not panic if an error occurs when sending the payload", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
targetURL,
httpmock.NewErrorResponder(errors.New("")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should return an error if the response type is incorrect", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
response := pushbullet.PushResponse{
Type: "link", // Incorrect type
Body: "Message",
Title: "Shoutrrr notification",
Active: true,
}
responder, _ := httpmock.NewJsonResponder(200, &response)
httpmock.RegisterResponder("POST", targetURL, responder)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("unexpected response type"))
})
ginkgo.It("should return an error if the response body does not match", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
response := pushbullet.PushResponse{
Type: "note",
Body: "Wrong message",
Title: "Shoutrrr notification",
Active: true,
}
responder, _ := httpmock.NewJsonResponder(200, &response)
httpmock.RegisterResponder("POST", targetURL, responder)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("response body mismatch"))
})
ginkgo.It("should return an error if the response title does not match", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
response := pushbullet.PushResponse{
Type: "note",
Body: "Message",
Title: "Wrong Title",
Active: true,
}
responder, _ := httpmock.NewJsonResponder(200, &response)
httpmock.RegisterResponder("POST", targetURL, responder)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("response title mismatch"))
})
ginkgo.It("should return an error if the push is not active", func() {
err = initService()
gomega.Expect(err).NotTo(gomega.HaveOccurred())
response := pushbullet.PushResponse{
Type: "note",
Body: "Message",
Title: "Shoutrrr notification", // Matches default
Active: false,
}
responder, _ := httpmock.NewJsonResponder(200, &response)
httpmock.RegisterResponder("POST", targetURL, responder)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("push notification is not active"))
})
})
})
// initService initializes the service with a fixed test configuration.
func initService() error {
serviceURL, err := url.Parse("pushbullet://tokentokentokentokentokentokentoke/test")
gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred())
return service.Initialize(serviceURL, testutils.TestLogger())
}