Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
279
pkg/router/router.go
Normal file
279
pkg/router/router.go
Normal file
|
@ -0,0 +1,279 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// DefaultTimeout is the default duration for service operation timeouts.
|
||||
const DefaultTimeout = 10 * time.Second
|
||||
|
||||
var (
|
||||
ErrNoSenders = errors.New("error sending message: no senders")
|
||||
ErrServiceTimeout = errors.New("failed to send: timed out")
|
||||
ErrCustomURLsNotSupported = errors.New("custom URLs are not supported by service")
|
||||
ErrUnknownService = errors.New("unknown service")
|
||||
ErrParseURLFailed = errors.New("failed to parse URL")
|
||||
ErrSendFailed = errors.New("failed to send message")
|
||||
ErrCustomURLConversion = errors.New("failed to convert custom URL")
|
||||
ErrInitializeFailed = errors.New("failed to initialize service")
|
||||
)
|
||||
|
||||
// ServiceRouter is responsible for routing a message to a specific notification service using the notification URL.
|
||||
type ServiceRouter struct {
|
||||
logger types.StdLogger
|
||||
services []types.Service
|
||||
queue []string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// New creates a new service router using the specified logger and service URLs.
|
||||
func New(logger types.StdLogger, serviceURLs ...string) (*ServiceRouter, error) {
|
||||
router := ServiceRouter{
|
||||
logger: logger,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
for _, serviceURL := range serviceURLs {
|
||||
if err := router.AddService(serviceURL); err != nil {
|
||||
return nil, fmt.Errorf("error initializing router services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &router, nil
|
||||
}
|
||||
|
||||
// AddService initializes the specified service from its URL, and adds it if no errors occur.
|
||||
func (router *ServiceRouter) AddService(serviceURL string) error {
|
||||
service, err := router.initService(serviceURL)
|
||||
if err == nil {
|
||||
router.services = append(router.services, service)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Send sends the specified message using the routers underlying services.
|
||||
func (router *ServiceRouter) Send(message string, params *types.Params) []error {
|
||||
if router == nil {
|
||||
return []error{ErrNoSenders}
|
||||
}
|
||||
|
||||
serviceCount := len(router.services)
|
||||
errors := make([]error, serviceCount)
|
||||
results := router.SendAsync(message, params)
|
||||
|
||||
for i := range router.services {
|
||||
errors[i] = <-results
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// SendItems sends the specified message items using the routers underlying services.
|
||||
func (router *ServiceRouter) SendItems(items []types.MessageItem, params types.Params) []error {
|
||||
if router == nil {
|
||||
return []error{ErrNoSenders}
|
||||
}
|
||||
|
||||
// Fallback using old API for now
|
||||
message := strings.Builder{}
|
||||
for _, item := range items {
|
||||
message.WriteString(item.Text)
|
||||
}
|
||||
|
||||
serviceCount := len(router.services)
|
||||
errors := make([]error, serviceCount)
|
||||
results := router.SendAsync(message.String(), ¶ms)
|
||||
|
||||
for i := range router.services {
|
||||
errors[i] = <-results
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// SendAsync sends the specified message using the routers underlying services.
|
||||
func (router *ServiceRouter) SendAsync(message string, params *types.Params) chan error {
|
||||
serviceCount := len(router.services)
|
||||
proxy := make(chan error, serviceCount)
|
||||
errors := make(chan error, serviceCount)
|
||||
|
||||
if params == nil {
|
||||
params = &types.Params{}
|
||||
}
|
||||
|
||||
for _, service := range router.services {
|
||||
go sendToService(service, proxy, router.Timeout, message, *params)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for range serviceCount {
|
||||
errors <- <-proxy
|
||||
}
|
||||
|
||||
close(errors)
|
||||
}()
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func sendToService(
|
||||
service types.Service,
|
||||
results chan error,
|
||||
timeout time.Duration,
|
||||
message string,
|
||||
params types.Params,
|
||||
) {
|
||||
result := make(chan error)
|
||||
|
||||
serviceID := service.GetID()
|
||||
|
||||
go func() { result <- service.Send(message, ¶ms) }()
|
||||
|
||||
select {
|
||||
case res := <-result:
|
||||
results <- res
|
||||
case <-time.After(timeout):
|
||||
results <- fmt.Errorf("%w: using %v", ErrServiceTimeout, serviceID)
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue adds the message to an internal queue and sends it when Flush is invoked.
|
||||
func (router *ServiceRouter) Enqueue(message string, v ...any) {
|
||||
if len(v) > 0 {
|
||||
message = fmt.Sprintf(message, v...)
|
||||
}
|
||||
|
||||
router.queue = append(router.queue, message)
|
||||
}
|
||||
|
||||
// Flush sends all messages that have been queued up as a combined message. This method should be deferred!
|
||||
func (router *ServiceRouter) Flush(params *types.Params) {
|
||||
// Since this method is supposed to be deferred we just have to ignore errors
|
||||
_ = router.Send(strings.Join(router.queue, "\n"), params)
|
||||
router.queue = []string{}
|
||||
}
|
||||
|
||||
// SetLogger sets the logger that the services will use to write progress logs.
|
||||
func (router *ServiceRouter) SetLogger(logger types.StdLogger) {
|
||||
router.logger = logger
|
||||
for _, service := range router.services {
|
||||
service.SetLogger(logger)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractServiceName from a notification URL.
|
||||
func (router *ServiceRouter) ExtractServiceName(rawURL string) (string, *url.URL, error) {
|
||||
serviceURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", &url.URL{}, fmt.Errorf("%s: %w", rawURL, ErrParseURLFailed)
|
||||
}
|
||||
|
||||
scheme := serviceURL.Scheme
|
||||
schemeParts := strings.Split(scheme, "+")
|
||||
|
||||
if len(schemeParts) > 1 {
|
||||
scheme = schemeParts[0]
|
||||
}
|
||||
|
||||
return scheme, serviceURL, nil
|
||||
}
|
||||
|
||||
// Route a message to a specific notification service using the notification URL.
|
||||
func (router *ServiceRouter) Route(rawURL string, message string) error {
|
||||
service, err := router.Locate(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := service.Send(message, nil); err != nil {
|
||||
return fmt.Errorf("%s: %w", service.GetID(), ErrSendFailed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (router *ServiceRouter) initService(rawURL string) (types.Service, error) {
|
||||
scheme, configURL, err := router.ExtractServiceName(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service, err := newService(scheme)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configURL.Scheme != scheme {
|
||||
router.log("Got custom URL:", configURL.String())
|
||||
|
||||
customURLService, ok := service.(types.CustomURLService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: '%s' service", ErrCustomURLsNotSupported, scheme)
|
||||
}
|
||||
|
||||
configURL, err = customURLService.GetConfigURLFromCustom(configURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", configURL.String(), ErrCustomURLConversion)
|
||||
}
|
||||
|
||||
router.log("Converted service URL:", configURL.String())
|
||||
}
|
||||
|
||||
err = service.Initialize(configURL, router.logger)
|
||||
if err != nil {
|
||||
return service, fmt.Errorf("%s: %w", scheme, ErrInitializeFailed)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewService returns a new uninitialized service instance.
|
||||
func (*ServiceRouter) NewService(serviceScheme string) (types.Service, error) {
|
||||
return newService(serviceScheme)
|
||||
}
|
||||
|
||||
// newService returns a new uninitialized service instance.
|
||||
func newService(serviceScheme string) (types.Service, error) {
|
||||
serviceFactory, valid := serviceMap[strings.ToLower(serviceScheme)]
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownService, serviceScheme)
|
||||
}
|
||||
|
||||
return serviceFactory(), nil
|
||||
}
|
||||
|
||||
// ListServices returns the available services.
|
||||
func (router *ServiceRouter) ListServices() []string {
|
||||
services := make([]string, len(serviceMap))
|
||||
|
||||
i := 0
|
||||
|
||||
for key := range serviceMap {
|
||||
services[i] = key
|
||||
i++
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
// Locate returns the service implementation that corresponds to the given service URL.
|
||||
func (router *ServiceRouter) Locate(rawURL string) (types.Service, error) {
|
||||
service, err := router.initService(rawURL)
|
||||
|
||||
return service, err
|
||||
}
|
||||
|
||||
func (router *ServiceRouter) log(v ...any) {
|
||||
if router.logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
router.logger.Println(v...)
|
||||
}
|
157
pkg/router/router_suite_test.go
Normal file
157
pkg/router/router_suite_test.go
Normal file
|
@ -0,0 +1,157 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Router Suite")
|
||||
}
|
||||
|
||||
var sr ServiceRouter
|
||||
|
||||
const (
|
||||
mockCustomURL = "teams+https://publicservice.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=publicservice.webhook.office.com"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("the router suite", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
sr = ServiceRouter{
|
||||
logger: log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags),
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.When("extract service name is given a url", func() {
|
||||
ginkgo.It("should extract the protocol/service part", func() {
|
||||
url := "slack://rest/of/url"
|
||||
serviceName, _, err := sr.ExtractServiceName(url)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(serviceName).To(gomega.Equal("slack"))
|
||||
})
|
||||
ginkgo.It("should extract the service part when provided in custom form", func() {
|
||||
url := "teams+https://rest/of/url"
|
||||
serviceName, _, err := sr.ExtractServiceName(url)
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(serviceName).To(gomega.Equal("teams"))
|
||||
})
|
||||
ginkgo.It("should return an error if the protocol/service part is missing", func() {
|
||||
url := "://rest/of/url"
|
||||
serviceName, _, err := sr.ExtractServiceName(url)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(serviceName).To(gomega.Equal(""))
|
||||
})
|
||||
ginkgo.It(
|
||||
"should return an error if the protocol/service part is containing invalid letters",
|
||||
func() {
|
||||
url := "a d://rest/of/url"
|
||||
serviceName, _, err := sr.ExtractServiceName(url)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(serviceName).To(gomega.Equal(""))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
ginkgo.When("initializing a service with a custom URL", func() {
|
||||
ginkgo.It("should return an error if the service does not support it", func() {
|
||||
service, err := sr.initService("log+https://hybr.is")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(service).To(gomega.BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("the service map", func() {
|
||||
ginkgo.When("resolving implemented services", func() {
|
||||
services := (&ServiceRouter{}).ListServices()
|
||||
|
||||
for _, scheme := range services {
|
||||
// copy ref to local closure
|
||||
serviceScheme := scheme
|
||||
|
||||
ginkgo.It(fmt.Sprintf("should return a Service for '%s'", serviceScheme), func() {
|
||||
service, err := newService(serviceScheme)
|
||||
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service).ToNot(gomega.BeNil())
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("initializing a service with a custom URL", func() {
|
||||
ginkgo.It("should return an error if the service does not support it", func() {
|
||||
service, err := sr.initService("log+https://hybr.is")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(service).To(gomega.BeNil())
|
||||
})
|
||||
ginkgo.It("should successfully init a service that does support it", func() {
|
||||
service, err := sr.initService(mockCustomURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service).NotTo(gomega.BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("a message is enqueued", func() {
|
||||
ginkgo.It("should be added to the internal queue", func() {
|
||||
sr.Enqueue("message body")
|
||||
gomega.Expect(sr.queue).ToNot(gomega.BeNil())
|
||||
gomega.Expect(sr.queue).To(gomega.HaveLen(1))
|
||||
})
|
||||
})
|
||||
ginkgo.When("a formatted message is enqueued", func() {
|
||||
ginkgo.It("should be added with the specified format", func() {
|
||||
sr.Enqueue("message with number %d", 5)
|
||||
gomega.Expect(sr.queue).ToNot(gomega.BeNil())
|
||||
gomega.Expect(sr.queue[0]).To(gomega.Equal("message with number 5"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("it leaves the scope after flush has been deferred", func() {
|
||||
ginkgo.When("it hasn't been assigned a sender", func() {
|
||||
ginkgo.It("should not cause a panic", func() {
|
||||
defer sr.Flush(nil)
|
||||
sr.Enqueue("message")
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("router has not been provided a logger", func() {
|
||||
ginkgo.It("should not crash when trying to log", func() {
|
||||
router := ServiceRouter{}
|
||||
_, err := router.initService(mockCustomURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func ExampleNew() {
|
||||
logger := log.New(os.Stdout, "", 0)
|
||||
|
||||
sr, err := New(logger, "logger://")
|
||||
if err != nil {
|
||||
log.Fatalf("could not create router: %s", err)
|
||||
}
|
||||
|
||||
sr.Send("hello", nil)
|
||||
// Output: hello
|
||||
}
|
||||
|
||||
func ExampleServiceRouter_Enqueue() {
|
||||
logger := log.New(os.Stdout, "", 0)
|
||||
|
||||
sr, err := New(logger, "logger://")
|
||||
if err != nil {
|
||||
log.Fatalf("could not create router: %s", err)
|
||||
}
|
||||
|
||||
defer sr.Flush(nil)
|
||||
sr.Enqueue("hello")
|
||||
sr.Enqueue("world")
|
||||
// Output:
|
||||
// hello
|
||||
// world
|
||||
}
|
51
pkg/router/servicemap.go
Normal file
51
pkg/router/servicemap.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/bark"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/discord"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/generic"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/googlechat"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/gotify"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/ifttt"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/join"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/lark"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/logger"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/matrix"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/mattermost"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/ntfy"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/opsgenie"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushover"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/rocketchat"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/slack"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/smtp"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/teams"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/telegram"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/zulip"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var serviceMap = map[string]func() types.Service{
|
||||
"bark": func() types.Service { return &bark.Service{} },
|
||||
"discord": func() types.Service { return &discord.Service{} },
|
||||
"generic": func() types.Service { return &generic.Service{} },
|
||||
"gotify": func() types.Service { return &gotify.Service{} },
|
||||
"googlechat": func() types.Service { return &googlechat.Service{} },
|
||||
"hangouts": func() types.Service { return &googlechat.Service{} },
|
||||
"ifttt": func() types.Service { return &ifttt.Service{} },
|
||||
"lark": func() types.Service { return &lark.Service{} },
|
||||
"join": func() types.Service { return &join.Service{} },
|
||||
"logger": func() types.Service { return &logger.Service{} },
|
||||
"matrix": func() types.Service { return &matrix.Service{} },
|
||||
"mattermost": func() types.Service { return &mattermost.Service{} },
|
||||
"ntfy": func() types.Service { return &ntfy.Service{} },
|
||||
"opsgenie": func() types.Service { return &opsgenie.Service{} },
|
||||
"pushbullet": func() types.Service { return &pushbullet.Service{} },
|
||||
"pushover": func() types.Service { return &pushover.Service{} },
|
||||
"rocketchat": func() types.Service { return &rocketchat.Service{} },
|
||||
"slack": func() types.Service { return &slack.Service{} },
|
||||
"smtp": func() types.Service { return &smtp.Service{} },
|
||||
"teams": func() types.Service { return &teams.Service{} },
|
||||
"telegram": func() types.Service { return &telegram.Service{} },
|
||||
"zulip": func() types.Service { return &zulip.Service{} },
|
||||
}
|
10
pkg/router/servicemap_xmpp.go
Normal file
10
pkg/router/servicemap_xmpp.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
//go:build xmpp
|
||||
// +build xmpp
|
||||
|
||||
package router
|
||||
|
||||
import t "github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
|
||||
func init() {
|
||||
serviceMap["xmpp"] = func() t.Service { return &xmpp.Service{} }
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue