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,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)
}

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

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

View 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", &params)
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))
})
})
})