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

279
pkg/router/router.go Normal file
View 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(), &params)
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, &params) }()
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...)
}

View 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
View 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{} },
}

View 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{} }
}