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
103
pkg/services/rocketchat/rocketchat.go
Normal file
103
pkg/services/rocketchat/rocketchat.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// defaultHTTPTimeout is the default timeout for HTTP requests.
|
||||
const defaultHTTPTimeout = 10 * time.Second
|
||||
|
||||
// ErrNotificationFailed indicates a failure in sending the notification.
|
||||
var ErrNotificationFailed = errors.New("notification failed")
|
||||
|
||||
// Service sends notifications to a pre-configured Rocket.Chat channel or user.
|
||||
type Service struct {
|
||||
standard.Standard
|
||||
Config *Config
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// Initialize configures the service with a URL and logger.
|
||||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
|
||||
service.SetLogger(logger)
|
||||
|
||||
service.Config = &Config{}
|
||||
if service.Client == nil {
|
||||
service.Client = &http.Client{
|
||||
Timeout: defaultHTTPTimeout, // Set a default timeout
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.Config.SetURL(configURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetID returns the service identifier.
|
||||
func (service *Service) GetID() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
// Send delivers a notification message to Rocket.Chat.
|
||||
func (service *Service) Send(message string, params *types.Params) error {
|
||||
var res *http.Response
|
||||
|
||||
var err error
|
||||
|
||||
config := service.Config
|
||||
apiURL := buildURL(config)
|
||||
json, _ := CreateJSONPayload(config, message, params)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(json))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err = service.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"posting to URL: %w\nHOST: %s\nPORT: %s",
|
||||
err,
|
||||
config.Host,
|
||||
config.Port,
|
||||
)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
resBody, _ := io.ReadAll(res.Body)
|
||||
|
||||
return fmt.Errorf("%w: %d %s", ErrNotificationFailed, res.StatusCode, resBody)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildURL constructs the API URL for Rocket.Chat based on the Config.
|
||||
func buildURL(config *Config) string {
|
||||
base := config.Host
|
||||
if config.Port != "" {
|
||||
base = net.JoinHostPort(config.Host, config.Port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s/hooks/%s/%s", base, config.TokenA, config.TokenB)
|
||||
}
|
91
pkg/services/rocketchat/rocketchat_config.go
Normal file
91
pkg/services/rocketchat/rocketchat_config.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
)
|
||||
|
||||
// Scheme is the identifying part of this service's configuration URL.
|
||||
const Scheme = "rocketchat"
|
||||
|
||||
// Constants for URL path length checks.
|
||||
const (
|
||||
MinPathParts = 3 // Minimum number of path parts required (including empty first slash)
|
||||
TokenBIndex = 2 // Index for TokenB in path
|
||||
ChannelIndex = 3 // Index for Channel in path
|
||||
)
|
||||
|
||||
// Static errors for configuration validation.
|
||||
var (
|
||||
ErrNotEnoughArguments = errors.New("the apiURL does not include enough arguments")
|
||||
)
|
||||
|
||||
// Config for the Rocket.Chat service.
|
||||
type Config struct {
|
||||
standard.EnumlessConfig
|
||||
UserName string `optional:"" url:"user"`
|
||||
Host string ` url:"host"`
|
||||
Port string ` url:"port"`
|
||||
TokenA string ` url:"path1"`
|
||||
Channel string ` url:"path3"`
|
||||
TokenB string ` url:"path2"`
|
||||
}
|
||||
|
||||
// GetURL returns a URL representation of the Config's current field values.
|
||||
func (config *Config) GetURL() *url.URL {
|
||||
host := config.Host
|
||||
if config.Port != "" {
|
||||
host = fmt.Sprintf("%s:%s", config.Host, config.Port)
|
||||
}
|
||||
|
||||
url := &url.URL{
|
||||
Host: host,
|
||||
Path: fmt.Sprintf("%s/%s", config.TokenA, config.TokenB),
|
||||
Scheme: Scheme,
|
||||
ForceQuery: false,
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// SetURL updates the Config from a URL representation of its field values.
|
||||
func (config *Config) SetURL(serviceURL *url.URL) error {
|
||||
userName := serviceURL.User.Username()
|
||||
host := serviceURL.Hostname()
|
||||
|
||||
path := strings.Split(serviceURL.Path, "/")
|
||||
if serviceURL.String() != "rocketchat://dummy@dummy.com" {
|
||||
if len(path) < MinPathParts {
|
||||
return ErrNotEnoughArguments
|
||||
}
|
||||
}
|
||||
|
||||
config.Port = serviceURL.Port()
|
||||
config.UserName = userName
|
||||
config.Host = host
|
||||
|
||||
if len(path) > 1 {
|
||||
config.TokenA = path[1]
|
||||
}
|
||||
|
||||
if len(path) > TokenBIndex {
|
||||
config.TokenB = path[TokenBIndex]
|
||||
}
|
||||
|
||||
if len(path) > ChannelIndex {
|
||||
switch {
|
||||
case serviceURL.Fragment != "":
|
||||
config.Channel = "#" + serviceURL.Fragment
|
||||
case !strings.HasPrefix(path[ChannelIndex], "@"):
|
||||
config.Channel = "#" + path[ChannelIndex]
|
||||
default:
|
||||
config.Channel = path[ChannelIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
41
pkg/services/rocketchat/rocketchat_json.go
Normal file
41
pkg/services/rocketchat/rocketchat_json.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// JSON represents the payload structure for the Rocket.Chat service.
|
||||
type JSON struct {
|
||||
Text string `json:"text"`
|
||||
UserName string `json:"username,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// CreateJSONPayload generates a JSON payload compatible with the Rocket.Chat webhook API.
|
||||
func CreateJSONPayload(config *Config, message string, params *types.Params) ([]byte, error) {
|
||||
payload := JSON{
|
||||
Text: message,
|
||||
UserName: config.UserName,
|
||||
Channel: config.Channel,
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
if value, found := (*params)["username"]; found {
|
||||
payload.UserName = value
|
||||
}
|
||||
|
||||
if value, found := (*params)["channel"]; found {
|
||||
payload.Channel = value
|
||||
}
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling Rocket.Chat payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
return payloadBytes, nil
|
||||
}
|
252
pkg/services/rocketchat/rocketchat_test.go
Normal file
252
pkg/services/rocketchat/rocketchat_test.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
service *Service
|
||||
envRocketchatURL *url.URL
|
||||
_ = ginkgo.BeforeSuite(func() {
|
||||
service = &Service{}
|
||||
envRocketchatURL, _ = url.Parse(os.Getenv("SHOUTRRR_ROCKETCHAT_URL"))
|
||||
})
|
||||
)
|
||||
|
||||
// Constants for repeated test values.
|
||||
const (
|
||||
testTokenA = "tokenA"
|
||||
testTokenB = "tokenB"
|
||||
)
|
||||
|
||||
func TestRocketchat(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Rocketchat Suite")
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("the rocketchat service", func() {
|
||||
// Add tests for Initialize()
|
||||
ginkgo.Describe("Initialize method", func() {
|
||||
ginkgo.When("initializing with a valid URL", func() {
|
||||
ginkgo.It("should set logger and config without error", func() {
|
||||
service := &Service{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://testUser@rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB + "/#testChannel",
|
||||
)
|
||||
err := service.Initialize(testURL, testutils.TestLogger())
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config).NotTo(gomega.BeNil())
|
||||
gomega.Expect(service.Config.Host).To(gomega.Equal("rocketchat.my-domain.com"))
|
||||
gomega.Expect(service.Config.Port).To(gomega.Equal("5055"))
|
||||
gomega.Expect(service.Config.UserName).To(gomega.Equal("testUser"))
|
||||
gomega.Expect(service.Config.TokenA).To(gomega.Equal(testTokenA))
|
||||
gomega.Expect(service.Config.TokenB).To(gomega.Equal(testTokenB))
|
||||
gomega.Expect(service.Config.Channel).To(gomega.Equal("#testChannel"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("initializing with an invalid URL", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
service := &Service{}
|
||||
testURL, _ := url.Parse("rocketchat://rocketchat.my-domain.com") // Missing tokens
|
||||
err := service.Initialize(testURL, testutils.TestLogger())
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err).
|
||||
To(gomega.Equal(ErrNotEnoughArguments))
|
||||
// Updated to use the error variable
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add tests for Send()
|
||||
ginkgo.Describe("Send method", func() {
|
||||
var (
|
||||
mockServer *httptest.Server
|
||||
service *Service
|
||||
client *http.Client
|
||||
)
|
||||
|
||||
ginkgo.BeforeEach(func() {
|
||||
// Create TLS server
|
||||
mockServer = httptest.NewTLSServer(nil) // Handler set in each test
|
||||
|
||||
// Configure client to trust the mock server's certificate
|
||||
certPool := x509.NewCertPool()
|
||||
for _, cert := range mockServer.TLS.Certificates {
|
||||
certPool.AddCert(cert.Leaf)
|
||||
}
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12, // Explicitly set minimum TLS version to 1.2
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service = &Service{
|
||||
Config: &Config{},
|
||||
Client: client, // Assign the custom client here
|
||||
}
|
||||
service.SetLogger(testutils.TestLogger())
|
||||
})
|
||||
|
||||
ginkgo.AfterEach(func() {
|
||||
if mockServer != nil {
|
||||
mockServer.Close()
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to a mock server with success", func() {
|
||||
ginkgo.It("should return no error", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to a mock server with failure", func() {
|
||||
ginkgo.It("should return an error with status code and body", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("bad request"))
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).
|
||||
To(gomega.ContainSubstring("notification failed: 400 bad request"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to an unreachable server", func() {
|
||||
ginkgo.It("should return a connection error", func() {
|
||||
service.Client = http.DefaultClient // Reset to default client for this test
|
||||
service.Config.Host = "nonexistent.domain"
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).To(gomega.ContainSubstring("posting to URL"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message with params overriding username and channel", func() {
|
||||
ginkgo.It("should use params values in the payload", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
service.Config.UserName = "defaultUser"
|
||||
service.Config.Channel = "#defaultChannel"
|
||||
|
||||
params := types.Params{
|
||||
"username": "overrideUser",
|
||||
"channel": "#overrideChannel",
|
||||
}
|
||||
err := service.Send("test message", ¶ms)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
// Note: We can't directly inspect the payload here without mocking CreateJSONPayload,
|
||||
// but this ensures the params path is exercised.
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add tests for GetURL() and SetURL()
|
||||
ginkgo.Describe("the rocketchat config", func() {
|
||||
ginkgo.When("generating a URL from a config with all fields", func() {
|
||||
ginkgo.It("should construct a correct URL", func() {
|
||||
config := &Config{
|
||||
Host: "rocketchat.my-domain.com",
|
||||
Port: "5055",
|
||||
TokenA: testTokenA,
|
||||
TokenB: testTokenB,
|
||||
}
|
||||
url := config.GetURL()
|
||||
gomega.Expect(url.String()).
|
||||
To(gomega.Equal("rocketchat://rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("generating a URL from a config without port", func() {
|
||||
ginkgo.It("should construct a correct URL without port", func() {
|
||||
config := &Config{
|
||||
Host: "rocketchat.my-domain.com",
|
||||
TokenA: testTokenA,
|
||||
TokenB: testTokenB,
|
||||
}
|
||||
url := config.GetURL()
|
||||
gomega.Expect(url.String()).
|
||||
To(gomega.Equal("rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("setting URL with a channel starting with @", func() {
|
||||
ginkgo.It("should set channel without adding #", func() {
|
||||
config := &Config{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/@user",
|
||||
)
|
||||
err := config.SetURL(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(config.Channel).To(gomega.Equal("@user"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("setting URL with a regular channel without fragment", func() {
|
||||
ginkgo.It("should prepend # to the channel", func() {
|
||||
config := &Config{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/general",
|
||||
)
|
||||
err := config.SetURL(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(config.Channel).To(gomega.Equal("#general"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add test for GetID()
|
||||
ginkgo.Describe("GetID method", func() {
|
||||
ginkgo.It("should return the correct scheme", func() {
|
||||
service := &Service{}
|
||||
id := service.GetID()
|
||||
gomega.Expect(id).To(gomega.Equal(Scheme))
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue