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

92
pkg/services/bark/bark.go Normal file
View file

@ -0,0 +1,92 @@
package bark
import (
"errors"
"fmt"
"net/http"
"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"
)
var (
ErrFailedAPIRequest = errors.New("failed to make API request")
ErrUnexpectedStatus = errors.New("unexpected status code")
ErrUpdateParamsFailed = errors.New("failed to update config from params")
)
// Service sends notifications to Bark.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send transmits a notification message to Bark.
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("%w: %w", ErrUpdateParamsFailed, err)
}
if err := service.sendAPI(config, message); err != nil {
return fmt.Errorf("failed to send bark notification: %w", err)
}
return nil
}
// Initialize sets up the Service with configuration from configURL and assigns a logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{}
service.pkr = format.NewPropKeyResolver(service.Config)
_ = service.pkr.SetDefaultProps(service.Config)
return service.Config.setURL(&service.pkr, configURL)
}
// GetID returns the identifier for the Bark service.
func (service *Service) GetID() string {
return Scheme
}
func (service *Service) sendAPI(config *Config, message string) error {
response := APIResponse{}
request := PushPayload{
Body: message,
DeviceKey: config.DeviceKey,
Title: config.Title,
Category: config.Category,
Copy: config.Copy,
Sound: config.Sound,
Group: config.Group,
Badge: &config.Badge,
Icon: config.Icon,
URL: config.URL,
}
jsonClient := jsonclient.NewClient()
if err := jsonClient.Post(config.GetAPIURL("push"), &request, &response); err != nil {
if jsonClient.ErrorResponse(err, &response) {
return &response
}
return fmt.Errorf("%w: %w", ErrFailedAPIRequest, err)
}
if response.Code != http.StatusOK {
if response.Message != "" {
return &response
}
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, response.Code)
}
return nil
}

View file

@ -0,0 +1,101 @@
package bark
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 identifying part of this service's configuration URL.
const (
Scheme = "bark"
)
// ErrSetQueryFailed indicates a failure to set a configuration value from a query parameter.
var ErrSetQueryFailed = errors.New("failed to set query parameter")
// Config holds configuration settings for the Bark service.
type Config struct {
standard.EnumlessConfig
Title string `default:"" desc:"Notification title, optionally set by the sender" key:"title"`
Host string ` desc:"Server hostname and port" url:"host"`
Path string `default:"/" desc:"Server path" url:"path"`
DeviceKey string ` desc:"The key for each device" url:"password"`
Scheme string `default:"https" desc:"Server protocol, http or https" key:"scheme"`
Sound string `default:"" desc:"Value from https://github.com/Finb/Bark/tree/master/Sounds" key:"sound"`
Badge int64 `default:"0" desc:"The number displayed next to App icon" key:"badge"`
Icon string `default:"" desc:"An url to the icon, available only on iOS 15 or later" key:"icon"`
Group string `default:"" desc:"The group of the notification" key:"group"`
URL string `default:"" desc:"Url that will jump when click notification" key:"url"`
Category string `default:"" desc:"Reserved field, no use yet" key:"category"`
Copy string `default:"" desc:"The value to be copied" key:"copy"`
}
// GetURL returns a URL representation of the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// GetAPIURL constructs the API URL for the specified endpoint using the current configuration.
func (config *Config) GetAPIURL(endpoint string) string {
path := strings.Builder{}
if !strings.HasPrefix(config.Path, "/") {
path.WriteByte('/')
}
path.WriteString(config.Path)
if !strings.HasSuffix(path.String(), "/") {
path.WriteByte('/')
}
path.WriteString(endpoint)
apiURL := url.URL{
Scheme: config.Scheme,
Host: config.Host,
Path: path.String(),
}
return apiURL.String()
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
User: url.UserPassword("", config.DeviceKey),
Host: config.Host,
Scheme: Scheme,
ForceQuery: true,
Path: config.Path,
RawQuery: format.BuildQuery(resolver),
}
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
password, _ := url.User.Password()
config.DeviceKey = password
config.Host = url.Host
config.Path = url.Path
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("%w '%s': %w", ErrSetQueryFailed, key, err)
}
}
return nil
}

View file

@ -0,0 +1,29 @@
package bark
// PushPayload represents the notification payload for the Bark notification service.
type PushPayload struct {
Body string `json:"body"`
DeviceKey string `json:"device_key"`
Title string `json:"title"`
Sound string `json:"sound,omitempty"`
Badge *int64 `json:"badge,omitempty"`
Icon string `json:"icon,omitempty"`
Group string `json:"group,omitempty"`
URL string `json:"url,omitempty"`
Category string `json:"category,omitempty"`
Copy string `json:"copy,omitempty"`
}
// APIResponse represents a response from the Bark API.
//
//nolint:errname
type APIResponse struct {
Code int64 `json:"code"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
}
// Error returns the error message from the API response when applicable.
func (e *APIResponse) Error() string {
return "server response: " + e.Message
}

View file

@ -0,0 +1,181 @@
package bark_test
import (
"log"
"net/http"
"net/url"
"os"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/services/bark"
)
// TestBark runs the Ginkgo test suite for the bark package.
func TestBark(t *testing.T) {
format.CharactersAroundMismatchToInclude = 20 // Show more context in failure output
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Bark Suite")
}
var (
service *bark.Service = &bark.Service{} // Bark service instance for testing
envBarkURL *url.URL // Environment-provided URL for integration tests
logger *log.Logger = testutils.TestLogger() // Shared logger for tests
_ = ginkgo.BeforeSuite(func() {
// Load the integration test URL from environment, if available
var err error
envBarkURL, err = url.Parse(os.Getenv("SHOUTRRR_BARK_URL"))
if err != nil {
envBarkURL = &url.URL{} // Default to empty URL if parsing fails
}
})
)
var _ = ginkgo.Describe("the bark service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
if envBarkURL.String() == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
configURL := testutils.URLMust(envBarkURL.String())
gomega.Expect(service.Initialize(configURL, logger)).To(gomega.Succeed())
gomega.Expect(service.Send("This is an integration test message", nil)).
To(gomega.Succeed())
})
})
ginkgo.Describe("the config", func() {
ginkgo.When("getting an API URL", func() {
ginkgo.It("constructs the expected URL for various path formats", func() {
gomega.Expect(getAPIForPath("path")).To(gomega.Equal("https://host/path/endpoint"))
gomega.Expect(getAPIForPath("/path")).To(gomega.Equal("https://host/path/endpoint"))
gomega.Expect(getAPIForPath("/path/")).
To(gomega.Equal("https://host/path/endpoint"))
gomega.Expect(getAPIForPath("path/")).To(gomega.Equal("https://host/path/endpoint"))
gomega.Expect(getAPIForPath("/")).To(gomega.Equal("https://host/endpoint"))
gomega.Expect(getAPIForPath("")).To(gomega.Equal("https://host/endpoint"))
})
})
ginkgo.When("only required fields are set", func() {
ginkgo.It("applies default values to optional fields", func() {
serviceURL := testutils.URLMust("bark://:devicekey@hostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
gomega.Expect(*service.Config).To(gomega.Equal(bark.Config{
Host: "hostname",
DeviceKey: "devicekey",
Scheme: "https",
}))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("preserves all fields after de-/serialization", func() {
testURL := "bark://:device-key@example.com:2225/?badge=5&category=CAT&group=GROUP&scheme=http&title=TITLE&url=URL"
config := &bark.Config{}
gomega.Expect(config.SetURL(testutils.URLMust(testURL))).
To(gomega.Succeed(), "verifying")
gomega.Expect(config.GetURL().String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.When("sending the push payload", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("sends successfully when the server accepts the payload", func() {
serviceURL := testutils.URLMust("bark://:devicekey@hostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"),
testutils.JSONRespondMust(200, bark.APIResponse{
Code: http.StatusOK,
Message: "OK",
}))
gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed())
})
ginkgo.It("reports an error for a server error response", func() {
serviceURL := testutils.URLMust("bark://:devicekey@hostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"),
testutils.JSONRespondMust(500, bark.APIResponse{
Code: 500,
Message: "someone turned off the internet",
}))
gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred())
})
ginkgo.It("handles an unexpected server response gracefully", func() {
serviceURL := testutils.URLMust("bark://:devicekey@hostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"),
testutils.JSONRespondMust(200, bark.APIResponse{
Code: 500,
Message: "For some reason, the response code and HTTP code is different?",
}))
gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred())
})
ginkgo.It("handles communication errors without panicking", func() {
httpmock.DeactivateAndReset() // Ensure no mocks interfere
serviceURL := testutils.URLMust("bark://:devicekey@nonresolvablehostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("the basic service API", func() {
ginkgo.Describe("the service config", func() {
ginkgo.It("implements basic service config API methods correctly", func() {
testutils.TestConfigGetInvalidQueryValue(&bark.Config{})
testutils.TestConfigSetInvalidQueryValue(
&bark.Config{},
"bark://:mock-device@host/?foo=bar",
)
testutils.TestConfigSetDefaultValues(&bark.Config{})
testutils.TestConfigGetEnumsCount(&bark.Config{}, 0)
testutils.TestConfigGetFieldsCount(&bark.Config{}, 9)
})
})
ginkgo.Describe("the service instance", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("implements basic service API methods correctly", func() {
serviceURL := testutils.URLMust("bark://:devicekey@hostname")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
testutils.TestServiceSetInvalidParamValue(service, "foo", "bar")
})
ginkgo.It("returns the correct service identifier", func() {
// No initialization needed since GetID is static
gomega.Expect(service.GetID()).To(gomega.Equal("bark"))
})
})
})
})
// getAPIForPath is a helper to construct an API URL for testing.
func getAPIForPath(path string) string {
c := bark.Config{Host: "host", Path: path, Scheme: "https"}
return c.GetAPIURL("endpoint")
}

View file

@ -0,0 +1,214 @@
package discord
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"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"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
const (
ChunkSize = 2000 // Maximum size of a single message chunk
TotalChunkSize = 6000 // Maximum total size of all chunks
ChunkCount = 10 // Maximum number of chunks allowed
MaxSearchRunes = 100 // Maximum number of runes to search for split position
HooksBaseURL = "https://discord.com/api/webhooks"
)
var (
ErrUnknownAPIError = errors.New("unknown error from Discord API")
ErrUnexpectedStatus = errors.New("unexpected response status code")
ErrInvalidURLPrefix = errors.New("URL must start with Discord webhook base URL")
ErrInvalidWebhookID = errors.New("invalid webhook ID")
ErrInvalidToken = errors.New("invalid token")
ErrEmptyURL = errors.New("empty URL provided")
ErrMalformedURL = errors.New("malformed URL: missing webhook ID or token")
)
var limits = types.MessageLimit{
ChunkSize: ChunkSize,
TotalChunkSize: TotalChunkSize,
ChunkCount: ChunkCount,
}
// Service implements a Discord notification service.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Discord.
func (service *Service) Send(message string, params *types.Params) error {
var firstErr error
if service.Config.JSON {
postURL := CreateAPIURLFromConfig(service.Config)
if err := doSend([]byte(message), postURL); err != nil {
return fmt.Errorf("sending JSON message: %w", err)
}
} else {
batches := CreateItemsFromPlain(message, service.Config.SplitLines)
for _, items := range batches {
if err := service.sendItems(items, params); err != nil {
service.Log(err)
if firstErr == nil {
firstErr = err
}
}
}
}
if firstErr != nil {
return fmt.Errorf("failed to send discord notification: %w", firstErr)
}
return nil
}
// SendItems delivers message items with enhanced metadata and formatting to Discord.
func (service *Service) SendItems(items []types.MessageItem, params *types.Params) error {
return service.sendItems(items, params)
}
func (service *Service) sendItems(items []types.MessageItem, 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)
}
payload, err := CreatePayloadFromItems(items, config.Title, config.LevelColors())
if err != nil {
return fmt.Errorf("creating payload: %w", err)
}
payload.Username = config.Username
payload.AvatarURL = config.Avatar
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshaling payload to JSON: %w", err)
}
postURL := CreateAPIURLFromConfig(&config)
return doSend(payloadBytes, postURL)
}
// CreateItemsFromPlain converts plain text into MessageItems suitable for Discord's webhook payload.
func CreateItemsFromPlain(plain string, splitLines bool) [][]types.MessageItem {
var batches [][]types.MessageItem
if splitLines {
return util.MessageItemsFromLines(plain, limits)
}
for {
items, omitted := util.PartitionMessage(plain, limits, MaxSearchRunes)
batches = append(batches, items)
if omitted == 0 {
break
}
plain = plain[len(plain)-omitted:]
}
return batches
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
if err := service.pkr.SetDefaultProps(service.Config); err != nil {
return fmt.Errorf("setting default properties: %w", err)
}
if err := service.Config.SetURL(configURL); err != nil {
return fmt.Errorf("setting config URL: %w", err)
}
return nil
}
// GetID provides the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// CreateAPIURLFromConfig builds a POST URL from the Discord configuration.
func CreateAPIURLFromConfig(config *Config) string {
if config.WebhookID == "" || config.Token == "" {
return "" // Invalid cases are caught in doSend
}
// Trim whitespace to prevent malformed URLs
webhookID := strings.TrimSpace(config.WebhookID)
token := strings.TrimSpace(config.Token)
baseURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token)
if config.ThreadID != "" {
// Append thread_id as a query parameter
query := url.Values{}
query.Set("thread_id", strings.TrimSpace(config.ThreadID))
return baseURL + "?" + query.Encode()
}
return baseURL
}
// doSend executes an HTTP POST request to deliver the payload to Discord.
//
//nolint:gosec,noctx
func doSend(payload []byte, postURL string) error {
if postURL == "" {
return ErrEmptyURL
}
parsedURL, err := url.ParseRequestURI(postURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if !strings.HasPrefix(parsedURL.String(), HooksBaseURL) {
return ErrInvalidURLPrefix
}
parts := strings.Split(strings.TrimPrefix(postURL, HooksBaseURL+"/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return ErrMalformedURL
}
webhookID := strings.TrimSpace(parts[0])
token := strings.TrimSpace(parts[1])
safeURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token)
res, err := http.Post(safeURL, "application/json", bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("making HTTP POST request: %w", err)
}
if res == nil {
return ErrUnknownAPIError
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status)
}
return nil
}

View file

@ -0,0 +1,121 @@
package discord
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 defines the protocol identifier for this service's configuration URL.
const Scheme = "discord"
// Static error definitions.
var (
ErrIllegalURLArgument = errors.New("illegal argument in config URL")
ErrMissingWebhookID = errors.New("webhook ID missing from config URL")
ErrMissingToken = errors.New("token missing from config URL")
)
// Config holds the settings required for sending Discord notifications.
type Config struct {
standard.EnumlessConfig
WebhookID string `url:"host"`
Token string `url:"user"`
Title string ` default:"" key:"title"`
Username string ` default:"" key:"username" desc:"Override the webhook default username"`
Avatar string ` default:"" key:"avatar,avatarurl" desc:"Override the webhook default avatar with specified URL"`
Color uint ` default:"0x50D9ff" key:"color" desc:"The color of the left border for plain messages" base:"16"`
ColorError uint ` default:"0xd60510" key:"colorError" desc:"The color of the left border for error messages" base:"16"`
ColorWarn uint ` default:"0xffc441" key:"colorWarn" desc:"The color of the left border for warning messages" base:"16"`
ColorInfo uint ` default:"0x2488ff" key:"colorInfo" desc:"The color of the left border for info messages" base:"16"`
ColorDebug uint ` default:"0x7b00ab" key:"colorDebug" desc:"The color of the left border for debug messages" base:"16"`
SplitLines bool ` default:"Yes" key:"splitLines" desc:"Whether to send each line as a separate embedded item"`
JSON bool ` default:"No" key:"json" desc:"Whether to send the whole message as the JSON payload instead of using it as the 'content' field"`
ThreadID string ` default:"" key:"thread_id" desc:"The thread ID to send the message to"`
}
// LevelColors returns an array of colors indexed by MessageLevel.
func (config *Config) LevelColors() [types.MessageLevelCount]uint {
var colors [types.MessageLevelCount]uint
colors[types.Unknown] = config.Color
colors[types.Error] = config.ColorError
colors[types.Warning] = config.ColorWarn
colors[types.Info] = config.ColorInfo
colors[types.Debug] = config.ColorDebug
return colors
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from configuration using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
url := &url.URL{
User: url.User(config.Token),
Host: config.WebhookID,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
ForceQuery: false,
}
if config.JSON {
url.Path = "/raw"
}
return url
}
// setURL updates the configuration from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
config.WebhookID = url.Host
config.Token = url.User.Username()
if len(url.Path) > 0 {
switch url.Path {
case "/raw":
config.JSON = true
default:
return ErrIllegalURLArgument
}
}
if config.WebhookID == "" {
return ErrMissingWebhookID
}
if len(config.Token) < 1 {
return ErrMissingToken
}
for key, vals := range url.Query() {
if key == "thread_id" {
// Trim whitespace from thread_id
config.ThreadID = strings.TrimSpace(vals[0])
continue
}
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config value for key %s: %w", key, err)
}
}
return nil
}

View file

@ -0,0 +1,86 @@
package discord
import (
"errors"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
const (
MaxEmbeds = 9
)
// Static error definition.
var ErrEmptyMessage = errors.New("message is empty")
// WebhookPayload is the webhook endpoint payload.
type WebhookPayload struct {
Embeds []embedItem `json:"embeds"`
Username string `json:"username,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// JSON is the actual notification payload.
type embedItem struct {
Title string `json:"title,omitempty"`
Content string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Color uint `json:"color,omitempty"`
Footer *embedFooter `json:"footer,omitempty"`
}
type embedFooter struct {
Text string `json:"text"`
IconURL string `json:"icon_url,omitempty"`
}
// CreatePayloadFromItems creates a JSON payload to be sent to the discord webhook API.
func CreatePayloadFromItems(
items []types.MessageItem,
title string,
colors [types.MessageLevelCount]uint,
) (WebhookPayload, error) {
if len(items) < 1 {
return WebhookPayload{}, ErrEmptyMessage
}
itemCount := util.Min(MaxEmbeds, len(items))
embeds := make([]embedItem, 0, itemCount)
for _, item := range items {
color := uint(0)
if item.Level >= types.Unknown && int(item.Level) < len(colors) {
color = colors[item.Level]
}
embeddedItem := embedItem{
Content: item.Text,
Color: color,
}
if item.Level != types.Unknown {
embeddedItem.Footer = &embedFooter{
Text: item.Level.String(),
}
}
if !item.Timestamp.IsZero() {
embeddedItem.Timestamp = item.Timestamp.UTC().Format(time.RFC3339)
}
embeds = append(embeds, embeddedItem)
}
// This should not happen, but it's better to leave the index check before dereferencing the array
if len(embeds) > 0 {
embeds[0].Title = title
}
return WebhookPayload{
Embeds: embeds,
}, nil
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,332 @@
package discord_test
import (
"fmt"
"log"
"net/url"
"os"
"strings"
"testing"
"time"
"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/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/discord"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestDiscord runs the Discord service test suite using Ginkgo.
func TestDiscord(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Discord Suite")
}
var (
dummyColors = [types.MessageLevelCount]uint{}
service *discord.Service
envDiscordURL *url.URL
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
service = &discord.Service{}
envDiscordURL, _ = url.Parse(os.Getenv("SHOUTRRR_DISCORD_URL"))
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
)
var _ = ginkgo.Describe("the discord service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should work without errors", func() {
if envDiscordURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envDiscordURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the service", func() {
ginkgo.It("should implement Service interface", func() {
var impl types.Service = service
gomega.Expect(impl).ToNot(gomega.BeNil())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("discord"))
})
})
ginkgo.Describe("creating a config", func() {
ginkgo.When("given a URL and a message", func() {
ginkgo.It("should return an error if no arguments are supplied", func() {
serviceURL, _ := url.Parse("discord://")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should not return an error if exactly two arguments are given", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should not return an error when given the raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set the JSON flag when given the raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw")
config := discord.Config{}
err := config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.JSON).To(gomega.BeTrue())
})
ginkgo.It("should not set the JSON flag when not provided raw path parameter", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel")
config := discord.Config{}
err := config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.JSON).NotTo(gomega.BeTrue())
})
ginkgo.It("should return an error if more than two arguments are given", func() {
serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/illegal-argument")
err := service.Initialize(serviceURL, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "discord://token@channel?avatar=TestBot.jpg&color=0x112233&colordebug=0x223344&colorerror=0x334455&colorinfo=0x445566&colorwarn=0x556677&splitlines=No&title=Test+Title&username=TestBot"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
ginkgo.It("should include thread_id in URL after de-/serialization", func() {
testURL := "discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err = resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
gomega.Expect(config.ThreadID).To(gomega.Equal("123456789"))
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
ginkgo.It("should handle thread_id with whitespace correctly", func() {
testURL := "discord://token@channel?color=0x50d9ff&thread_id=%20%20123456789%20%20&title=Test+Title"
expectedThreadID := "123456789"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err = resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
gomega.Expect(config.ThreadID).To(gomega.Equal(expectedThreadID))
gomega.Expect(config.GetURL().Query().Get("thread_id")).
To(gomega.Equal(expectedThreadID))
gomega.Expect(config.GetURL().String()).
To(gomega.Equal("discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title"))
})
ginkgo.It("should not include thread_id in URL when empty", func() {
config := &discord.Config{}
resolver := format.NewPropKeyResolver(config)
err := resolver.SetDefaultProps(config)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults")
serviceURL, _ := url.Parse("discord://token@channel?title=Test+Title")
err = config.SetURL(serviceURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting URL")
outputURL := config.GetURL()
gomega.Expect(outputURL.Query().Get("thread_id")).To(gomega.BeEmpty())
gomega.Expect(outputURL.String()).
To(gomega.Equal("discord://token@channel?color=0x50d9ff&title=Test+Title"))
})
})
})
ginkgo.Describe("creating a json payload", func() {
ginkgo.When("given a blank message", func() {
ginkgo.When("split lines is enabled", func() {
ginkgo.It("should return an error", func() {
items := []types.MessageItem{}
gomega.Expect(items).To(gomega.BeEmpty())
_, err := discord.CreatePayloadFromItems(items, "title", dummyColors)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("split lines is disabled", func() {
ginkgo.It("should return an error", func() {
batches := discord.CreateItemsFromPlain("", false)
items := batches[0]
gomega.Expect(items).To(gomega.BeEmpty())
_, err := discord.CreatePayloadFromItems(items, "title", dummyColors)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
ginkgo.When("given a message that exceeds the max length", func() {
ginkgo.It("should return a payload with chunked messages", func() {
payload, err := buildPayloadFromHundreds(42, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
items := payload.Embeds
gomega.Expect(items).To(gomega.HaveLen(3))
gomega.Expect(items[0].Content).To(gomega.HaveLen(1994))
gomega.Expect(items[1].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[2].Content).To(gomega.HaveLen(205))
})
ginkgo.It("omit characters above total max", func() {
payload, err := buildPayloadFromHundreds(62, "", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
items := payload.Embeds
gomega.Expect(items).To(gomega.HaveLen(4))
gomega.Expect(items[0].Content).To(gomega.HaveLen(1994))
gomega.Expect(items[1].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[2].Content).To(gomega.HaveLen(1999))
gomega.Expect(items[3].Content).To(gomega.HaveLen(5))
})
ginkgo.When("no title is supplied and content fits", func() {
ginkgo.It("should return a payload without a meta chunk", func() {
payload, err := buildPayloadFromHundreds(42, "", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(payload.Embeds[0].Footer).To(gomega.BeNil())
gomega.Expect(payload.Embeds[0].Title).To(gomega.BeEmpty())
})
})
ginkgo.When("title is supplied, but content fits", func() {
ginkgo.It("should return a payload with a meta chunk", func() {
payload, err := buildPayloadFromHundreds(42, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(payload.Embeds[0].Title).ToNot(gomega.BeEmpty())
})
})
ginkgo.It("rich test 1", func() {
testTime, _ := time.Parse(time.RFC3339, time.RFC3339)
items := []types.MessageItem{
{
Text: "Message",
Timestamp: testTime,
Level: types.Warning,
},
}
payload, err := discord.CreatePayloadFromItems(items, "Title", dummyColors)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
item := payload.Embeds[0]
gomega.Expect(payload.Embeds).To(gomega.HaveLen(1))
gomega.Expect(item.Footer.Text).To(gomega.Equal(types.Warning.String()))
gomega.Expect(item.Title).To(gomega.Equal("Title"))
gomega.Expect(item.Color).To(gomega.Equal(dummyColors[types.Warning]))
})
})
})
ginkgo.Describe("sending the payload", func() {
dummyConfig := discord.Config{
WebhookID: "1",
Token: "dummyToken",
}
var service discord.Service
ginkgo.BeforeEach(func() {
httpmock.Activate()
service = discord.Service{}
if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil {
panic(fmt.Errorf("service initialization failed: %w", err))
}
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
setupResponder(&dummyConfig, 204)
gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed())
})
ginkgo.It("should report an error if the server response is not OK", func() {
setupResponder(&dummyConfig, 400)
gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed())
})
ginkgo.It("should report an error if the message is empty", func() {
setupResponder(&dummyConfig, 204)
gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("", nil)).NotTo(gomega.Succeed())
})
ginkgo.When("using a custom json payload", func() {
ginkgo.It("should report an error if the server response is not OK", func() {
config := dummyConfig
config.JSON = true
setupResponder(&config, 400)
gomega.Expect(service.Initialize(config.GetURL(), logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed())
})
})
ginkgo.It("should trim whitespace from thread_id in API URL", func() {
config := discord.Config{
WebhookID: "1",
Token: "dummyToken",
ThreadID: " 123456789 ",
}
service := discord.Service{}
err := service.Initialize(config.GetURL(), logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
setupResponder(&config, 204)
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
// Verify the API URL used in the HTTP request
targetURL := discord.CreateAPIURLFromConfig(&config)
gomega.Expect(targetURL).
To(gomega.Equal("https://discord.com/api/webhooks/1/dummyToken?thread_id=123456789"))
})
})
})
// buildPayloadFromHundreds creates a Discord webhook payload from a repeated 100-character string.
func buildPayloadFromHundreds(
hundreds int,
title string,
colors [types.MessageLevelCount]uint,
) (discord.WebhookPayload, error) {
hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error"
builder := strings.Builder{}
for range hundreds {
builder.WriteString(hundredChars)
}
batches := discord.CreateItemsFromPlain(
builder.String(),
false,
) // SplitLines is always false in these tests
items := batches[0]
return discord.CreatePayloadFromItems(items, title, colors)
}
// setupResponder configures an HTTP mock responder for a Discord webhook URL with the given status code.
func setupResponder(config *discord.Config, code int) {
targetURL := discord.CreateAPIURLFromConfig(config)
httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(code, ""))
}

View file

@ -0,0 +1,74 @@
package generic
import (
"net/url"
"strings"
)
// Constants for character values and offsets.
const (
ExtraPrefixChar = '$' // Prefix for extra data in query parameters
HeaderPrefixChar = '@' // Prefix for header values in query parameters
CaseOffset = 'a' - 'A' // Offset between lowercase and uppercase letters
UppercaseA = 'A' // ASCII value for uppercase A
UppercaseZ = 'Z' // ASCII value for uppercase Z
DashChar = '-' // Dash character for header formatting
HeaderCapacityFactor = 2 // Estimated capacity multiplier for header string builder
)
func normalizedHeaderKey(key string) string {
stringBuilder := strings.Builder{}
stringBuilder.Grow(len(key) * HeaderCapacityFactor)
for i, c := range key {
if UppercaseA <= c && c <= UppercaseZ {
// Char is uppercase
if i > 0 && key[i-1] != DashChar {
// Add missing dash
stringBuilder.WriteRune(DashChar)
}
} else if i == 0 || key[i-1] == DashChar {
// First char, or previous was dash
c -= CaseOffset
}
stringBuilder.WriteRune(c)
}
return stringBuilder.String()
}
func appendCustomQueryValues(
query url.Values,
headers map[string]string,
extraData map[string]string,
) {
for key, value := range headers {
query.Set(string(HeaderPrefixChar)+key, value)
}
for key, value := range extraData {
query.Set(string(ExtraPrefixChar)+key, value)
}
}
func stripCustomQueryValues(query url.Values) (map[string]string, map[string]string) {
headers := make(map[string]string)
extraData := make(map[string]string)
for key, values := range query {
switch key[0] {
case HeaderPrefixChar:
headerKey := normalizedHeaderKey(key[1:])
headers[headerKey] = values[0]
case ExtraPrefixChar:
extraData[key[1:]] = values[0]
default:
continue
}
delete(query, key)
}
return headers, extraData
}

View file

@ -0,0 +1,181 @@
package generic
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"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"
)
// JSONTemplate identifies the JSON format for webhook payloads.
const (
JSONTemplate = "JSON"
)
// ErrSendFailed indicates a failure to send a notification to the generic webhook.
var (
ErrSendFailed = errors.New("failed to send notification to generic webhook")
ErrUnexpectedStatus = errors.New("server returned unexpected response status code")
ErrTemplateNotLoaded = errors.New("template has not been loaded")
)
// Service implements a generic notification service for custom webhooks.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to a generic webhook endpoint.
func (service *Service) Send(message string, paramsPtr *types.Params) error {
config := *service.Config
var params types.Params
if paramsPtr == nil {
params = types.Params{}
} else {
params = *paramsPtr
}
if err := service.pkr.UpdateConfigFromParams(&config, &params); err != nil {
service.Logf("Failed to update params: %v", err)
}
sendParams := createSendParams(&config, params, message)
if err := service.doSend(&config, sendParams); err != nil {
return fmt.Errorf("%w: %s", ErrSendFailed, err.Error())
}
return nil
}
// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
config, pkr := DefaultConfig()
service.Config = config
service.pkr = pkr
return service.Config.setURL(&service.pkr, configURL)
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// GetConfigURLFromCustom converts a custom webhook URL into a standard service URL.
func (*Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) {
webhookURL := *customURL
if strings.HasPrefix(webhookURL.Scheme, Scheme) {
webhookURL.Scheme = webhookURL.Scheme[len(Scheme)+1:]
}
config, pkr, err := ConfigFromWebhookURL(webhookURL)
if err != nil {
return nil, err
}
return config.getURL(&pkr), nil
}
// doSend executes the HTTP request to send a notification to the webhook.
func (service *Service) doSend(config *Config, params types.Params) error {
postURL := config.WebhookURL().String()
payload, err := service.GetPayload(config, params)
if err != nil {
return err
}
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, config.RequestMethod, postURL, payload)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", config.ContentType)
req.Header.Set("Accept", config.ContentType)
for key, value := range config.headers {
req.Header.Set(key, value)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("sending HTTP request: %w", err)
}
if res != nil && res.Body != nil {
defer res.Body.Close()
if body, err := io.ReadAll(res.Body); err == nil {
service.Log("Server response: ", string(body))
}
}
if res.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status)
}
return nil
}
// GetPayload prepares the request payload based on the configured template.
func (service *Service) GetPayload(config *Config, params types.Params) (io.Reader, error) {
switch config.Template {
case "":
return bytes.NewBufferString(params[config.MessageKey]), nil
case "json", JSONTemplate:
for key, value := range config.extraData {
params[key] = value
}
jsonBytes, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshaling params to JSON: %w", err)
}
return bytes.NewBuffer(jsonBytes), nil
}
tpl, found := service.GetTemplate(config.Template)
if !found {
return nil, fmt.Errorf("%w: %q", ErrTemplateNotLoaded, config.Template)
}
bb := &bytes.Buffer{}
if err := tpl.Execute(bb, params); err != nil {
return nil, fmt.Errorf("executing template %q: %w", config.Template, err)
}
return bb, nil
}
// createSendParams constructs parameters for sending a notification.
func createSendParams(config *Config, params types.Params, message string) types.Params {
sendParams := types.Params{}
for key, val := range params {
if key == types.TitleKey {
key = config.TitleKey
}
sendParams[key] = val
}
sendParams[config.MessageKey] = message
return sendParams
}

View file

@ -0,0 +1,123 @@
package generic
import (
"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"
)
// Scheme identifies this service in configuration URLs.
const (
Scheme = "generic"
DefaultWebhookScheme = "https"
)
// Config holds settings for the generic notification service.
type Config struct {
standard.EnumlessConfig
webhookURL *url.URL
headers map[string]string
extraData map[string]string
ContentType string `default:"application/json" desc:"The value of the Content-Type header" key:"contenttype"`
DisableTLS bool `default:"No" key:"disabletls"`
Template string ` desc:"The template used for creating the request payload" key:"template" optional:""`
Title string `default:"" key:"title"`
TitleKey string `default:"title" desc:"The key that will be used for the title value" key:"titlekey"`
MessageKey string `default:"message" desc:"The key that will be used for the message value" key:"messagekey"`
RequestMethod string `default:"POST" key:"method"`
}
// DefaultConfig creates a new Config with default values and its associated PropKeyResolver.
func DefaultConfig() (*Config, format.PropKeyResolver) {
config := &Config{}
pkr := format.NewPropKeyResolver(config)
_ = pkr.SetDefaultProps(config)
return config, pkr
}
// ConfigFromWebhookURL constructs a Config from a parsed webhook URL.
func ConfigFromWebhookURL(webhookURL url.URL) (*Config, format.PropKeyResolver, error) {
config, pkr := DefaultConfig()
webhookQuery := webhookURL.Query()
headers, extraData := stripCustomQueryValues(webhookQuery)
escapedQuery := url.Values{}
for key, values := range webhookQuery {
if len(values) > 0 {
escapedQuery.Set(format.EscapeKey(key), values[0])
}
}
_, err := format.SetConfigPropsFromQuery(&pkr, escapedQuery)
if err != nil {
return nil, pkr, fmt.Errorf("setting config properties from query: %w", err)
}
webhookURL.RawQuery = webhookQuery.Encode()
config.webhookURL = &webhookURL
config.headers = headers
config.extraData = extraData
config.DisableTLS = webhookURL.Scheme == "http"
return config, pkr, nil
}
// WebhookURL returns the configured webhook URL, adjusted for TLS settings.
func (config *Config) WebhookURL() *url.URL {
webhookURL := *config.webhookURL
webhookURL.Scheme = DefaultWebhookScheme
if config.DisableTLS {
webhookURL.Scheme = "http" // Truncate to "http" if TLS is disabled
}
return &webhookURL
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a service URL.
func (config *Config) SetURL(serviceURL *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, serviceURL)
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
serviceURL := *config.webhookURL
webhookQuery := config.webhookURL.Query()
serviceQuery := format.BuildQueryWithCustomFields(resolver, webhookQuery)
appendCustomQueryValues(serviceQuery, config.headers, config.extraData)
serviceURL.RawQuery = serviceQuery.Encode()
serviceURL.Scheme = Scheme
return &serviceURL
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error {
webhookURL := *serviceURL
serviceQuery := serviceURL.Query()
headers, extraData := stripCustomQueryValues(serviceQuery)
customQuery, err := format.SetConfigPropsFromQuery(resolver, serviceQuery)
if err != nil {
return fmt.Errorf("setting config properties from service URL query: %w", err)
}
webhookURL.RawQuery = customQuery.Encode()
config.webhookURL = &webhookURL
config.headers = headers
config.extraData = extraData
return nil
}

View file

@ -0,0 +1,359 @@
package generic_test
import (
"errors"
"io"
"log"
"net/http"
"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/generic"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Test constants.
const (
TestWebhookURL = "https://host.tld/webhook" // Default test webhook URL
)
// TestGeneric runs the Ginkgo test suite for the generic package.
func TestGeneric(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Generic Webhook Suite")
}
var (
service *generic.Service
logger *log.Logger
envGenericURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &generic.Service{}
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
var err error
envGenericURL, err = url.Parse(os.Getenv("SHOUTRRR_GENERIC_URL"))
if err != nil {
envGenericURL = &url.URL{} // Default to empty URL if parsing fails
}
})
)
var _ = ginkgo.Describe("the generic service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
if envGenericURL.String() == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
serviceURL := testutils.URLMust(envGenericURL.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.Describe("the service", func() {
ginkgo.BeforeEach(func() {
service = &generic.Service{}
service.SetLogger(logger)
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("generic"))
})
})
ginkgo.When("parsing a custom URL", func() {
ginkgo.BeforeEach(func() {
service = &generic.Service{}
service.SetLogger(logger)
})
ginkgo.It("correctly sets webhook URL from custom URL", func() {
customURL := testutils.URLMust("generic+https://test.tld")
serviceURL, err := service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.WebhookURL().String()).To(gomega.Equal("https://test.tld"))
})
ginkgo.When("a HTTP URL is provided via query parameter", func() {
ginkgo.It("disables TLS", func() {
config := &generic.Config{}
err := config.SetURL(testutils.URLMust("generic://example.com?disabletls=yes"))
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.DisableTLS).To(gomega.BeTrue())
})
})
ginkgo.When("a HTTPS URL is provided", func() {
ginkgo.It("enables TLS", func() {
config := &generic.Config{}
err := config.SetURL(testutils.URLMust("generic://example.com"))
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.DisableTLS).To(gomega.BeFalse())
})
})
ginkgo.It("escapes conflicting custom query keys", func() {
serviceURL := testutils.URLMust("generic://example.com/?__template=passed")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Template).NotTo(gomega.Equal("passed"))
whURL := service.Config.WebhookURL().String()
gomega.Expect(whURL).To(gomega.Equal("https://example.com/?template=passed"))
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(serviceURL.String()))
})
ginkgo.It("handles both escaped and service prop versions of keys", func() {
serviceURL := testutils.URLMust(
"generic://example.com/?__template=passed&template=captured",
)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Template).To(gomega.Equal("captured"))
whURL := service.Config.WebhookURL().String()
gomega.Expect(whURL).To(gomega.Equal("https://example.com/?template=passed"))
})
})
ginkgo.When("retrieving the webhook URL", func() {
ginkgo.BeforeEach(func() {
service = &generic.Service{}
service.SetLogger(logger)
})
ginkgo.It("builds a valid webhook URL", func() {
serviceURL := testutils.URLMust("generic://example.com/path?foo=bar")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.WebhookURL().String()).
To(gomega.Equal("https://example.com/path?foo=bar"))
})
ginkgo.When("TLS is disabled", func() {
ginkgo.It("uses http scheme", func() {
serviceURL := testutils.URLMust("generic://test.tld?disabletls=yes")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.WebhookURL().Scheme).To(gomega.Equal("http"))
})
})
ginkgo.When("TLS is not disabled", func() {
ginkgo.It("uses https scheme", func() {
serviceURL := testutils.URLMust("generic://test.tld")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.WebhookURL().Scheme).To(gomega.Equal("https"))
})
})
})
ginkgo.Describe("the generic config", func() {
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("is identical after de-/serialization", func() {
testURL := "generic://user:pass@host.tld/api/v1/webhook?$context=inside-joke&@Authorization=frend&__title=w&contenttype=a%2Fb&template=f&title=t"
expectedURL := "generic://user:pass@host.tld/api/v1/webhook?%24context=inside-joke&%40Authorization=frend&__title=w&contenttype=a%2Fb&template=f&title=t"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(expectedURL))
})
})
})
ginkgo.Describe("building the payload", func() {
ginkgo.BeforeEach(func() {
service = &generic.Service{}
service.SetLogger(logger)
})
ginkgo.When("no template is specified", func() {
ginkgo.It("uses the message as payload", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
payload, err := service.GetPayload(
service.Config,
types.Params{"message": "test message"},
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
contents, err := io.ReadAll(payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(contents)).To(gomega.Equal("test message"))
})
})
ginkgo.When("template is specified as `JSON`", func() {
ginkgo.It("creates a JSON object as the payload", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?template=JSON")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{"title": "test title", "message": "test message"}
payload, err := service.GetPayload(service.Config, params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
contents, err := io.ReadAll(payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(contents)).To(gomega.MatchJSON(`{
"title": "test title",
"message": "test message"
}`))
})
ginkgo.When("alternate keys are specified", func() {
ginkgo.It("creates a JSON object using the specified keys", func() {
serviceURL := testutils.URLMust(
"generic://host.tld/webhook?template=JSON&messagekey=body&titlekey=header",
)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{"header": "test title", "body": "test message"}
payload, err := service.GetPayload(service.Config, params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
contents, err := io.ReadAll(payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(contents)).To(gomega.MatchJSON(`{
"header": "test title",
"body": "test message"
}`))
})
})
})
ginkgo.When("a valid template is specified", func() {
ginkgo.It("applies the template to the message payload", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?template=news")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.SetTemplateString("news", `{{.title}} ==> {{.message}}`)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{"title": "BREAKING NEWS", "message": "it's today!"}
payload, err := service.GetPayload(service.Config, params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
contents, err := io.ReadAll(payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(contents)).To(gomega.Equal("BREAKING NEWS ==> it's today!"))
})
ginkgo.When("given nil params", func() {
ginkgo.It("applies template with message data", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?template=arrows")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.SetTemplateString("arrows", `==> {{.message}} <==`)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
payload, err := service.GetPayload(
service.Config,
types.Params{"message": "LOOK AT ME"},
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
contents, err := io.ReadAll(payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(contents)).To(gomega.Equal("==> LOOK AT ME <=="))
})
})
})
ginkgo.When("an unknown template is specified", func() {
ginkgo.It("returns an error", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?template=missing")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
_, err = service.GetPayload(service.Config, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
ginkgo.Describe("sending the payload", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
service = &generic.Service{}
service.SetLogger(logger)
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.When("sending via webhook URL", func() {
ginkgo.It("succeeds if the server accepts the payload", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
TestWebhookURL,
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("reports an error if sending fails", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
TestWebhookURL,
httpmock.NewErrorResponder(errors.New("dummy error")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("includes custom headers in the request", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?@authorization=frend")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder("POST", TestWebhookURL,
func(req *http.Request) (*http.Response, error) {
gomega.Expect(req.Header.Get("Authorization")).To(gomega.Equal("frend"))
return httpmock.NewStringResponse(200, ""), nil
})
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("includes extra data in JSON payload", func() {
serviceURL := testutils.URLMust(
"generic://host.tld/webhook?template=json&$context=inside+joke",
)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder("POST", TestWebhookURL,
func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(body)).
To(gomega.MatchJSON(`{"message":"Message","context":"inside joke"}`))
return httpmock.NewStringResponse(200, ""), nil
})
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("uses the configured HTTP method", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?method=GET")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"GET",
TestWebhookURL,
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("does not mutate the given params", func() {
serviceURL := testutils.URLMust("generic://host.tld/webhook?method=GET")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"GET",
TestWebhookURL,
httpmock.NewStringResponder(200, ""),
)
params := types.Params{"title": "TITLE"}
err = service.Send("Message", &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(params).To(gomega.Equal(types.Params{"title": "TITLE"}))
})
})
})
})

View file

@ -0,0 +1,87 @@
package googlechat
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// ErrUnexpectedStatus indicates an unexpected HTTP status code from the Google Chat API.
var ErrUnexpectedStatus = errors.New("google chat api returned unexpected http status code")
// Service implements a Google Chat notification service.
type Service struct {
standard.Standard
Config *Config
}
// 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{}
return service.Config.SetURL(configURL)
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// Send delivers a notification message to Google Chat.
func (service *Service) Send(message string, _ *types.Params) error {
config := service.Config
jsonBody, err := json.Marshal(JSON{Text: message})
if err != nil {
return fmt.Errorf("marshaling message to JSON: %w", err)
}
postURL := getAPIURL(config)
jsonBuffer := bytes.NewBuffer(jsonBody)
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
postURL.String(),
jsonBuffer,
)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("sending notification to Google Chat: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, resp.StatusCode)
}
return nil
}
// getAPIURL constructs the API URL for Google Chat notifications.
func getAPIURL(config *Config) *url.URL {
query := url.Values{}
query.Set("key", config.Key)
query.Set("token", config.Token)
return &url.URL{
Path: config.Path,
Host: config.Host,
Scheme: "https",
RawQuery: query.Encode(),
}
}

View file

@ -0,0 +1,73 @@
package googlechat
import (
"errors"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
Scheme = "googlechat"
)
// Static error definitions.
var (
ErrMissingKey = errors.New("missing field 'key'")
ErrMissingToken = errors.New("missing field 'token'")
)
type Config struct {
standard.EnumlessConfig
Host string `default:"chat.googleapis.com"`
Path string
Token string
Key string
}
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
func (config *Config) setURL(_ types.ConfigQueryResolver, serviceURL *url.URL) error {
config.Host = serviceURL.Host
config.Path = serviceURL.Path
query := serviceURL.Query()
config.Key = query.Get("key")
config.Token = query.Get("token")
// Only enforce if explicitly provided but empty
if query.Has("key") && config.Key == "" {
return ErrMissingKey
}
if query.Has("token") && config.Token == "" {
return ErrMissingToken
}
return nil
}
func (config *Config) getURL(_ types.ConfigQueryResolver) *url.URL {
query := url.Values{}
query.Set("key", config.Key)
query.Set("token", config.Token)
return &url.URL{
Host: config.Host,
Path: config.Path,
RawQuery: query.Encode(),
Scheme: Scheme,
}
}

View file

@ -0,0 +1,6 @@
package googlechat
// JSON is the actual payload being sent to the Google Chat API.
type JSON struct {
Text string `json:"text"`
}

View file

@ -0,0 +1,220 @@
package googlechat_test
import (
"errors"
"io"
"log"
"net/http"
"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/googlechat"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestGooglechat runs the Ginkgo test suite for the Google Chat package.
func TestGooglechat(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Google Chat Suite")
}
var (
service *googlechat.Service
logger *log.Logger
envGooglechatURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &googlechat.Service{}
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
var err error
envGooglechatURL, err = url.Parse(os.Getenv("SHOUTRRR_GOOGLECHAT_URL"))
if err != nil {
envGooglechatURL = &url.URL{} // Default to empty URL if parsing fails
}
})
)
var _ = ginkgo.Describe("Google Chat Service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
if envGooglechatURL.String() == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
serviceURL := testutils.URLMust(envGooglechatURL.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.Describe("the service", func() {
ginkgo.BeforeEach(func() {
service = &googlechat.Service{}
service.SetLogger(logger)
})
ginkgo.It("implements Service interface", func() {
var impl types.Service = service
gomega.Expect(impl).ToNot(gomega.BeNil())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("googlechat"))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.BeforeEach(func() {
service = &googlechat.Service{}
service.SetLogger(logger)
})
ginkgo.It("builds a valid Google Chat Incoming Webhook URL", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String()))
})
ginkgo.It("is identical after de-/serialization", func() {
testURL := "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL))
})
ginkgo.It("returns an error if key is present but empty", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).To(gomega.MatchError("missing field 'key'"))
})
ginkgo.It("returns an error if token is present but empty", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).To(gomega.MatchError("missing field 'token'"))
})
})
ginkgo.Describe("sending the payload", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
service = &googlechat.Service{}
service.SetLogger(logger)
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.When("sending via webhook URL", func() {
ginkgo.It("does not report an error if the server accepts the payload", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("reports an error if the server rejects the payload", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
httpmock.NewStringResponder(400, "Bad Request"),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("marshals the payload correctly with the message", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(body)).To(gomega.MatchJSON(`{"text":"Test Message"}`))
return httpmock.NewStringResponse(200, ""), nil
},
)
err = service.Send("Test Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("sends the POST request with correct URL and content type", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
func(req *http.Request) (*http.Response, error) {
gomega.Expect(req.Method).To(gomega.Equal("POST"))
gomega.Expect(req.Header.Get("Content-Type")).
To(gomega.Equal("application/json"))
return httpmock.NewStringResponse(200, ""), nil
},
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns marshal error if JSON marshaling fails", func() {
// Note: Current JSON struct (string) can't fail marshaling naturally
// This test is a placeholder for future complex payload changes
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Valid Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns formatted error if HTTP POST fails", func() {
configURL := testutils.URLMust(
"googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
httpmock.NewErrorResponder(errors.New("network failure")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.MatchError(
"sending notification to Google Chat: Post \"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz\": network failure",
))
})
})
})
})

View file

@ -0,0 +1,148 @@
package gotify
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"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"
)
const (
// HTTPTimeout defines the HTTP client timeout in seconds.
HTTPTimeout = 10
TokenLength = 15
// TokenChars specifies the valid characters for a Gotify token.
TokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
)
// ErrInvalidToken indicates an invalid Gotify token format or content.
var ErrInvalidToken = errors.New("invalid gotify token")
// Service implements a Gotify notification service.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
httpClient *http.Client
client jsonclient.Client
}
// Initialize configures the service with a URL and logger.
//
//nolint:gosec
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{
Title: "Shoutrrr notification",
}
service.pkr = format.NewPropKeyResolver(service.Config)
err := service.Config.SetURL(configURL)
if err != nil {
return err
}
service.httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// InsecureSkipVerify disables TLS certificate verification when true.
// This is set to Config.DisableTLS to support HTTP or self-signed certificate setups,
// but it reduces security by allowing potential man-in-the-middle attacks.
InsecureSkipVerify: service.Config.DisableTLS,
},
},
Timeout: HTTPTimeout * time.Second,
}
if service.Config.DisableTLS {
service.Log("Warning: TLS verification is disabled, making connections insecure")
}
service.client = jsonclient.NewWithHTTPClient(service.httpClient)
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// isTokenValid checks if a Gotify token meets length and character requirements.
// Rules are based on Gotify's token validation logic.
func isTokenValid(token string) bool {
if len(token) != TokenLength || token[0] != 'A' {
return false
}
for _, c := range token {
if !strings.ContainsRune(TokenChars, c) {
return false
}
}
return true
}
// buildURL constructs the Gotify API URL with scheme, host, path, and token.
func buildURL(config *Config) (string, error) {
token := config.Token
if !isTokenValid(token) {
return "", fmt.Errorf("%w: %q", ErrInvalidToken, token)
}
scheme := "https"
if config.DisableTLS {
scheme = "http" // Use HTTP if TLS is disabled
}
return fmt.Sprintf("%s://%s%s/message?token=%s", scheme, config.Host, config.Path, token), nil
}
// Send delivers a notification message to Gotify.
func (service *Service) Send(message string, params *types.Params) error {
if params == nil {
params = &types.Params{}
}
config := service.Config
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
service.Logf("Failed to update params: %v", err)
}
postURL, err := buildURL(config)
if err != nil {
return err
}
request := &messageRequest{
Message: message,
Title: config.Title,
Priority: config.Priority,
}
response := &messageResponse{}
err = service.client.Post(postURL, request, response)
if err != nil {
errorRes := &responseError{}
if service.client.ErrorResponse(err, errorRes) {
return errorRes
}
return fmt.Errorf("failed to send notification to Gotify: %w", err)
}
return nil
}
// GetHTTPClient returns the HTTP client for testing purposes.
func (service *Service) GetHTTPClient() *http.Client {
return service.httpClient
}

View file

@ -0,0 +1,76 @@
package gotify
import (
"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 identifies this service in configuration URLs.
const (
Scheme = "gotify"
)
// Config holds settings for the Gotify notification service.
type Config struct {
standard.EnumlessConfig
Token string `desc:"Application token" required:"" url:"path2"`
Host string `desc:"Server hostname (and optionally port)" required:"" url:"host,port"`
Path string `desc:"Server subpath" url:"path1" optional:""`
Priority int ` default:"0" key:"priority"`
Title string ` default:"Shoutrrr notification" key:"title"`
DisableTLS bool ` default:"No" key:"disabletls"`
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
Host: config.Host,
Scheme: Scheme,
ForceQuery: false,
Path: config.Path + config.Token,
RawQuery: format.BuildQuery(resolver),
}
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
path := url.Path
if len(path) > 0 && path[len(path)-1] == '/' {
path = path[:len(path)-1]
}
tokenIndex := strings.LastIndex(path, "/") + 1
config.Path = path[:tokenIndex]
if config.Path == "/" {
config.Path = config.Path[1:]
}
config.Host = url.Host
config.Token = path[tokenIndex:]
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config property %q from URL query: %w", key, err)
}
}
return nil
}

View file

@ -0,0 +1,27 @@
package gotify
import "fmt"
// messageRequest is the actual payload being sent to the Gotify API.
type messageRequest struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
}
type messageResponse struct {
messageRequest
ID uint64 `json:"id"`
AppID uint64 `json:"appid"`
Date string `json:"date"`
}
type responseError struct {
Name string `json:"error"`
Code uint64 `json:"errorCode"`
Description string `json:"errorDescription"`
}
func (er *responseError) Error() string {
return fmt.Sprintf("server respondend with %v (%v): %v", er.Name, er.Code, er.Description)
}

View file

@ -0,0 +1,246 @@
package gotify_test
import (
"bytes"
"errors"
"log"
"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/gotify"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Test constants.
const (
TargetURL = "https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd"
)
// TestGotify runs the Ginkgo test suite for the Gotify package.
func TestGotify(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Gotify Suite")
}
var (
service *gotify.Service
logger *log.Logger
envGotifyURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &gotify.Service{}
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
var err error
envGotifyURL, err = url.Parse(os.Getenv("SHOUTRRR_GOTIFY_URL"))
if err != nil {
envGotifyURL = &url.URL{} // Default to empty URL if parsing fails
}
})
)
var _ = ginkgo.Describe("the Gotify service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
if envGotifyURL.String() == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
serviceURL := testutils.URLMust(envGotifyURL.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.Describe("the service", func() {
ginkgo.BeforeEach(func() {
service = &gotify.Service{}
service.SetLogger(logger)
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("gotify"))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.BeforeEach(func() {
service = &gotify.Service{}
service.SetLogger(logger)
})
ginkgo.It("builds a valid Gotify URL without path", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String()))
})
ginkgo.When("TLS is disabled", func() {
ginkgo.It("uses http scheme", func() {
configURL := testutils.URLMust(
"gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=yes",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.DisableTLS).To(gomega.BeTrue())
})
})
ginkgo.When("a custom path is provided", func() {
ginkgo.It("includes the path in the URL", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String()))
})
})
ginkgo.When("the token has an invalid length", func() {
ginkgo.It("reports an error during send", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/short") // Length < 15
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"short\""))
})
})
ginkgo.When("the token has an invalid prefix", func() {
ginkgo.It("reports an error during send", func() {
configURL := testutils.URLMust(
"gotify://my.gotify.tld/Chwbsdyhwwgarxd",
) // Starts with 'C', not 'A'
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Message", nil)
gomega.Expect(err).
To(gomega.MatchError("invalid gotify token: \"Chwbsdyhwwgarxd\""))
})
})
ginkgo.It("is identical after de-/serialization with path", func() {
testURL := "gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd?title=Test+title"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL))
})
ginkgo.It("is identical after de-/serialization without path", func() {
testURL := "gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=Yes&priority=1&title=Test+title"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL))
})
ginkgo.It("allows slash at the end of the token", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd/")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd"))
})
ginkgo.It("allows slash at the end of the token with additional path", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/path/to/gotify/Aaa.bbb.ccc.ddd/")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd"))
})
ginkgo.It("does not crash on empty token or path slash", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld//")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Token).To(gomega.Equal(""))
})
})
ginkgo.When("the token contains invalid characters", func() {
ginkgo.It("reports an error during send", func() {
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.dd!")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"Aaa.bbb.ccc.dd!\""))
})
})
ginkgo.Describe("sending the payload", func() {
ginkgo.BeforeEach(func() {
service = &gotify.Service{}
service.SetLogger(logger)
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.ActivateNonDefault(service.GetHTTPClient())
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.When("sending via webhook URL", func() {
ginkgo.It("does not report an error if the server accepts the payload", func() {
httpmock.RegisterResponder(
"POST",
TargetURL,
testutils.JSONRespondMust(200, map[string]any{
"id": float64(1),
"appid": float64(1),
"message": "Message",
"title": "Shoutrrr notification",
"priority": float64(0),
"date": "2023-01-01T00:00:00Z",
}),
)
err := service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It(
"reports an error if the server rejects the payload with an error response",
func() {
httpmock.RegisterResponder(
"POST",
TargetURL,
testutils.JSONRespondMust(401, map[string]any{
"error": "Unauthorized",
"errorCode": float64(401),
"errorDescription": "you need to provide a valid access token or user credentials to access this api",
}),
)
err := service.Send("Message", nil)
gomega.Expect(err).
To(gomega.MatchError("server respondend with Unauthorized (401): you need to provide a valid access token or user credentials to access this api"))
},
)
ginkgo.It("reports an error if sending fails with a network error", func() {
httpmock.RegisterResponder(
"POST",
TargetURL,
httpmock.NewErrorResponder(errors.New("network failure")),
)
err := service.Send("Message", nil)
gomega.Expect(err).
To(gomega.MatchError("failed to send notification to Gotify: sending POST request to \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": Post \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": network failure"))
})
ginkgo.It("logs an error if params update fails", func() {
var logBuffer bytes.Buffer
service.SetLogger(log.New(&logBuffer, "Test", log.LstdFlags))
httpmock.RegisterResponder(
"POST",
TargetURL,
testutils.JSONRespondMust(200, map[string]any{
"id": float64(1),
"appid": float64(1),
"message": "Message",
"title": "Shoutrrr notification",
"priority": float64(0),
"date": "2023-01-01T00:00:00Z",
}),
)
params := types.Params{"priority": "invalid"}
err := service.Send("Message", &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(logBuffer.String()).
To(gomega.ContainSubstring("Failed to update params"))
})
})
})
})

106
pkg/services/ifttt/ifttt.go Normal file
View file

@ -0,0 +1,106 @@
package ifttt
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// apiURLFormat defines the IFTTT webhook URL template.
const (
apiURLFormat = "https://maker.ifttt.com/trigger/%s/with/key/%s"
)
// ErrSendFailed indicates a failure to send an IFTTT event notification.
var (
ErrSendFailed = errors.New("failed to send IFTTT event")
ErrUnexpectedStatus = errors.New("got unexpected response status code")
)
// Service sends notifications to an IFTTT webhook.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// 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{
UseMessageAsValue: DefaultMessageValue,
}
service.pkr = format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// Send delivers a notification message to an IFTTT webhook.
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)
}
payload, err := createJSONToSend(config, message, params)
if err != nil {
return err
}
for _, event := range config.Events {
apiURL := service.createAPIURLForEvent(event)
if err := doSend(payload, apiURL); err != nil {
return fmt.Errorf("%w: event %q: %w", ErrSendFailed, event, err)
}
}
return nil
}
// createAPIURLForEvent builds an IFTTT webhook URL for a specific event.
func (service *Service) createAPIURLForEvent(event string) string {
return fmt.Sprintf(apiURLFormat, event, service.Config.WebHookID)
}
// doSend executes an HTTP POST request to send the payload to the IFTTT webhook.
func doSend(payload []byte, postURL string) error {
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
postURL,
bytes.NewBuffer(payload),
)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("sending HTTP request to IFTTT webhook: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status)
}
return nil
}

View file

@ -0,0 +1,107 @@
package ifttt
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"
)
const (
Scheme = "ifttt" // Scheme identifies this service in configuration URLs.
DefaultMessageValue = 2 // Default value field (1-3) for the notification message
DisabledValue = 0 // Value to disable title assignment
MinValueField = 1 // Minimum valid value field (Value1)
MaxValueField = 3 // Maximum valid value field (Value3)
MinLength = 1 // Minimum length for required fields like Events and WebHookID
)
var (
ErrInvalidMessageValue = errors.New(
"invalid value for messagevalue: only values 1-3 are supported",
)
ErrInvalidTitleValue = errors.New(
"invalid value for titlevalue: only values 1-3 or 0 (for disabling) are supported",
)
ErrTitleMessageConflict = errors.New("titlevalue cannot use the same number as messagevalue")
ErrMissingEvents = errors.New("events missing from config URL")
ErrMissingWebhookID = errors.New("webhook ID missing from config URL")
)
// Config holds settings for the IFTTT notification service.
type Config struct {
standard.EnumlessConfig
WebHookID string `required:"true" url:"host"`
Events []string `required:"true" key:"events"`
Value1 string ` key:"value1" optional:""`
Value2 string ` key:"value2" optional:""`
Value3 string ` key:"value3" optional:""`
UseMessageAsValue uint8 ` key:"messagevalue" default:"2" desc:"Sets the corresponding value field to the notification message"`
UseTitleAsValue uint8 ` key:"titlevalue" default:"0" desc:"Sets the corresponding value field to the notification title"`
Title string ` key:"title" default:"" desc:"Notification title, optionally set by the sender"`
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
Host: config.WebHookID,
Path: "/",
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
}
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
if config.UseMessageAsValue == DisabledValue {
config.UseMessageAsValue = DefaultMessageValue
}
config.WebHookID = url.Hostname()
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config property %q from URL query: %w", key, err)
}
}
if config.UseMessageAsValue > MaxValueField || config.UseMessageAsValue < MinValueField {
return ErrInvalidMessageValue
}
if config.UseTitleAsValue > MaxValueField {
return ErrInvalidTitleValue
}
if config.UseTitleAsValue != DisabledValue &&
config.UseTitleAsValue == config.UseMessageAsValue {
return ErrTitleMessageConflict
}
if url.String() != "ifttt://dummy@dummy.com" {
if len(config.Events) < MinLength {
return ErrMissingEvents
}
if len(config.WebHookID) < MinLength {
return ErrMissingWebhookID
}
}
return nil
}

View file

@ -0,0 +1,61 @@
package ifttt
import (
"encoding/json"
"fmt"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// ValueFieldOne represents the Value1 field in the IFTTT payload.
const (
ValueFieldOne = 1 // Represents Value1 field
ValueFieldTwo = 2 // Represents Value2 field
ValueFieldThree = 3 // Represents Value3 field
)
// jsonPayload represents the notification payload sent to the IFTTT webhook API.
type jsonPayload struct {
Value1 string `json:"value1"`
Value2 string `json:"value2"`
Value3 string `json:"value3"`
}
// createJSONToSend generates a JSON payload for the IFTTT webhook API.
func createJSONToSend(config *Config, message string, params *types.Params) ([]byte, error) {
payload := jsonPayload{
Value1: config.Value1,
Value2: config.Value2,
Value3: config.Value3,
}
if params != nil {
if value, found := (*params)["value1"]; found {
payload.Value1 = value
}
if value, found := (*params)["value2"]; found {
payload.Value2 = value
}
if value, found := (*params)["value3"]; found {
payload.Value3 = value
}
}
switch config.UseMessageAsValue {
case ValueFieldOne:
payload.Value1 = message
case ValueFieldTwo:
payload.Value2 = message
case ValueFieldThree:
payload.Value3 = message
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshaling IFTTT payload to JSON: %w", err)
}
return jsonBytes, nil
}

View file

@ -0,0 +1,335 @@
package ifttt_test
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"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/ifttt"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestIFTTT runs the Ginkgo test suite for the IFTTT package.
func TestIFTTT(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr IFTTT Suite")
}
var (
service *ifttt.Service
logger *log.Logger
envTestURL string
_ = ginkgo.BeforeSuite(func() {
service = &ifttt.Service{}
logger = testutils.TestLogger()
envTestURL = os.Getenv("SHOUTRRR_IFTTT_URL")
})
)
var _ = ginkgo.Describe("the IFTTT service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
if envTestURL == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
serviceURL := testutils.URLMust(envTestURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("This is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the service", func() {
ginkgo.BeforeEach(func() {
service = &ifttt.Service{}
service.SetLogger(logger)
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("ifttt"))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.BeforeEach(func() {
service = &ifttt.Service{}
service.SetLogger(logger)
})
ginkgo.It("returns an error if no arguments are supplied", func() {
serviceURL := testutils.URLMust("ifttt://")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("returns an error if no webhook ID is given", func() {
serviceURL := testutils.URLMust("ifttt:///?events=event1")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("returns an error if no events are given", func() {
serviceURL := testutils.URLMust("ifttt://dummyID")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("returns an error when an invalid query key is given", func() { // Line 54
serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&badquery=foo")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("returns an error if message value is above 3", func() {
serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&messagevalue=8")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("returns an error if message value is below 1", func() { // Line 60
serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&messagevalue=0")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It(
"does not return an error if webhook ID and at least one event are given",
func() {
serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
},
)
ginkgo.It("returns an error if titlevalue is invalid", func() { // Line 78
serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&titlevalue=4")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).
To(gomega.MatchError("invalid value for titlevalue: only values 1-3 or 0 (for disabling) are supported"))
})
ginkgo.It("returns an error if titlevalue equals messagevalue", func() { // Line 82
serviceURL := testutils.URLMust(
"ifttt://dummyID/?events=event1&messagevalue=2&titlevalue=2",
)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).
To(gomega.MatchError("titlevalue cannot use the same number as messagevalue"))
})
})
ginkgo.When("serializing a config to URL", func() {
ginkgo.BeforeEach(func() {
service = &ifttt.Service{}
service.SetLogger(logger)
})
ginkgo.When("given multiple events", func() {
ginkgo.It("returns an URL with all events comma-separated", func() {
configURL := testutils.URLMust("ifttt://dummyID/?events=foo%2Cbar%2Cbaz")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
resultURL := service.Config.GetURL().String()
gomega.Expect(resultURL).To(gomega.Equal(configURL.String()))
})
})
ginkgo.When("given values", func() {
ginkgo.It("returns an URL with all values", func() {
configURL := testutils.URLMust(
"ifttt://dummyID/?events=event1&value1=v1&value2=v2&value3=v3",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
resultURL := service.Config.GetURL().String()
gomega.Expect(resultURL).To(gomega.Equal(configURL.String()))
})
})
})
ginkgo.Describe("sending a message", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
service = &ifttt.Service{}
service.SetLogger(logger)
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("errors if the response code is not 200-299", func() {
configURL := testutils.URLMust("ifttt://dummy/?events=foo")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/foo/with/key/dummy",
httpmock.NewStringResponder(404, ""),
)
err = service.Send("hello", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("does not error if the response code is 200", func() {
configURL := testutils.URLMust("ifttt://dummy/?events=foo")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/foo/with/key/dummy",
httpmock.NewStringResponder(200, ""),
)
err = service.Send("hello", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns an error if params update fails", func() { // Line 55
configURL := testutils.URLMust("ifttt://dummy/?events=event1")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{"messagevalue": "invalid"}
err = service.Send("hello", &params)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.DescribeTable("sets message to correct value field based on messagevalue",
func(messageValue int, expectedField string) { // Lines 30, 32, 34
configURL := testutils.URLMust(
fmt.Sprintf("ifttt://dummy/?events=event1&messagevalue=%d", messageValue),
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event1/with/key/dummy",
func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
var payload jsonPayload
err = json.Unmarshal(body, &payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
switch expectedField {
case "Value1":
gomega.Expect(payload.Value1).To(gomega.Equal("hello"))
gomega.Expect(payload.Value2).To(gomega.Equal(""))
gomega.Expect(payload.Value3).To(gomega.Equal(""))
case "Value2":
gomega.Expect(payload.Value1).To(gomega.Equal(""))
gomega.Expect(payload.Value2).To(gomega.Equal("hello"))
gomega.Expect(payload.Value3).To(gomega.Equal(""))
case "Value3":
gomega.Expect(payload.Value1).To(gomega.Equal(""))
gomega.Expect(payload.Value2).To(gomega.Equal(""))
gomega.Expect(payload.Value3).To(gomega.Equal("hello"))
}
return httpmock.NewStringResponse(200, ""), nil
},
)
err = service.Send("hello", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
},
ginkgo.Entry("messagevalue=1 sets Value1", 1, "Value1"),
ginkgo.Entry("messagevalue=2 sets Value2", 2, "Value2"),
ginkgo.Entry("messagevalue=3 sets Value3", 3, "Value3"),
)
ginkgo.It("overrides Value2 with params when messagevalue is 1", func() { // Line 36
configURL := testutils.URLMust("ifttt://dummy/?events=event1&messagevalue=1")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event1/with/key/dummy",
func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
var payload jsonPayload
err = json.Unmarshal(body, &payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload.Value1).To(gomega.Equal("hello"))
gomega.Expect(payload.Value2).To(gomega.Equal("y"))
gomega.Expect(payload.Value3).To(gomega.Equal(""))
return httpmock.NewStringResponse(200, ""), nil
},
)
params := types.Params{
"value2": "y",
}
err = service.Send("hello", &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("overrides payload values with params", func() { // Lines 17, 21, 25
configURL := testutils.URLMust(
"ifttt://dummy/?events=event1&value1=a&value2=b&value3=c&messagevalue=2",
)
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event1/with/key/dummy",
func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
var payload jsonPayload
err = json.Unmarshal(body, &payload)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload.Value1).To(gomega.Equal("x"))
gomega.Expect(payload.Value2).To(gomega.Equal("hello"))
gomega.Expect(payload.Value3).To(gomega.Equal("z"))
return httpmock.NewStringResponse(200, ""), nil
},
)
params := types.Params{
"value1": "x",
// "value2": "y", // Omitted to let message override
"value3": "z",
}
err = service.Send("hello", &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should fail with multiple events when one errors", func() {
configURL := testutils.URLMust("ifttt://dummy/?events=event1,event2")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event1/with/key/dummy",
httpmock.NewStringResponder(200, ""),
)
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event2/with/key/dummy",
httpmock.NewStringResponder(404, "Not Found"),
)
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.MatchError(
`failed to send IFTTT event: event "event2": got unexpected response status code: 404 Not Found`,
))
})
ginkgo.It("should fail with network error", func() {
configURL := testutils.URLMust("ifttt://dummy/?events=event1")
err := service.Initialize(configURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://maker.ifttt.com/trigger/event1/with/key/dummy",
httpmock.NewErrorResponder(errors.New("network failure")),
)
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.MatchError(
`failed to send IFTTT event: event "event1": sending HTTP request to IFTTT webhook: Post "https://maker.ifttt.com/trigger/event1/with/key/dummy": network failure`,
))
})
})
})
type jsonPayload struct {
Value1 string `json:"value1"`
Value2 string `json:"value2"`
Value3 string `json:"value3"`
}

119
pkg/services/join/join.go Normal file
View file

@ -0,0 +1,119 @@
package join
import (
"context"
"errors"
"fmt"
"net/http"
"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"
)
const (
// hookURL defines the Join API endpoint for sending push notifications.
hookURL = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush"
contentType = "text/plain"
)
// ErrSendFailed indicates a failure to send a notification to Join devices.
var ErrSendFailed = errors.New("failed to send notification to join devices")
// Service sends notifications to Join devices.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Join devices.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config
if params == nil {
params = &types.Params{}
}
title, found := (*params)["title"]
if !found {
title = config.Title
}
icon, found := (*params)["icon"]
if !found {
icon = config.Icon
}
devices := strings.Join(config.Devices, ",")
return service.sendToDevices(devices, message, title, icon)
}
func (service *Service) sendToDevices(devices, message, title, icon string) error {
config := service.Config
apiURL, err := url.Parse(hookURL)
if err != nil {
return fmt.Errorf("parsing Join API URL: %w", err)
}
data := url.Values{}
data.Set("deviceIds", devices)
data.Set("apikey", config.APIKey)
data.Set("text", message)
if len(title) > 0 {
data.Set("title", title)
}
if len(icon) > 0 {
data.Set("icon", icon)
}
apiURL.RawQuery = data.Encode()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
apiURL.String(),
nil,
)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", contentType)
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("sending HTTP request to Join: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %q, response status %q", ErrSendFailed, devices, res.Status)
}
return nil
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}

View file

@ -0,0 +1,79 @@
package join
import (
"errors"
"fmt"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme identifies this service in configuration URLs.
const Scheme = "join"
// ErrDevicesMissing indicates that no devices are specified in the configuration.
var (
ErrDevicesMissing = errors.New("devices missing from config URL")
ErrAPIKeyMissing = errors.New("API key missing from config URL")
)
// Config holds settings for the Join notification service.
type Config struct {
APIKey string `url:"pass"`
Devices []string ` desc:"Comma separated list of device IDs" key:"devices"`
Title string ` desc:"If set creates a notification" key:"title" optional:""`
Icon string ` desc:"Icon URL" key:"icon" optional:""`
}
// Enums returns the fields that should use an EnumFormatter for their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
User: url.UserPassword("Token", config.APIKey),
Host: "join",
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
password, _ := url.User.Password()
config.APIKey = password
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config property %q from URL query: %w", key, err)
}
}
if url.String() != "join://dummy@dummy.com" {
if len(config.Devices) < 1 {
return ErrDevicesMissing
}
if len(config.APIKey) < 1 {
return ErrAPIKeyMissing
}
}
return nil
}

View file

@ -0,0 +1,12 @@
package join
// ErrorMessage for error events within the pushover service.
type ErrorMessage string
const (
// APIKeyMissing should be used when a config URL is missing a token.
APIKeyMissing ErrorMessage = "API key missing from config URL" //nolint:gosec // false positive
// DevicesMissing should be used when a config URL is missing devices.
DevicesMissing ErrorMessage = "devices missing from config URL"
)

View file

@ -0,0 +1,173 @@
package join_test
import (
"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/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/join"
)
func TestJoin(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Join Suite")
}
var (
service *join.Service
config *join.Config
pkr format.PropKeyResolver
envJoinURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &join.Service{}
envJoinURL, _ = url.Parse(os.Getenv("SHOUTRRR_JOIN_URL"))
})
)
var _ = ginkgo.Describe("the join service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should work", func() {
if envJoinURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envJoinURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("join"))
})
})
})
var _ = ginkgo.Describe("the join config", func() {
ginkgo.BeforeEach(func() {
config = &join.Config{}
pkr = format.NewPropKeyResolver(config)
})
ginkgo.When("updating it using an url", func() {
ginkgo.It("should update the API key using the password part of the url", func() {
url := createURL("dummy", "TestToken", "testDevice")
err := config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.APIKey).To(gomega.Equal("TestToken"))
})
ginkgo.It("should error if supplied with an empty token", func() {
url := createURL("user", "", "testDevice")
expectErrorMessageGivenURL(join.APIKeyMissing, url)
})
})
ginkgo.When("getting the current config", func() {
ginkgo.It("should return the config that is currently set as an url", func() {
config.APIKey = "test-token"
url := config.GetURL()
password, _ := url.User.Password()
gomega.Expect(password).To(gomega.Equal(config.APIKey))
gomega.Expect(url.Scheme).To(gomega.Equal("join"))
})
})
ginkgo.When("setting a config key", func() {
ginkgo.It("should split it by commas if the key is devices", func() {
err := pkr.Set("devices", "a,b,c,d")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Devices).To(gomega.Equal([]string{"a", "b", "c", "d"}))
})
ginkgo.It("should update icon when an icon is supplied", func() {
err := pkr.Set("icon", "https://example.com/icon.png")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Icon).To(gomega.Equal("https://example.com/icon.png"))
})
ginkgo.It("should update the title when it is supplied", func() {
err := pkr.Set("title", "new title")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Title).To(gomega.Equal("new title"))
})
ginkgo.It("should return an error if the key is not recognized", func() {
err := pkr.Set("devicey", "a,b,c,d")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("getting a config key", func() {
ginkgo.It("should join it with commas if the key is devices", func() {
config.Devices = []string{"a", "b", "c"}
value, err := pkr.Get("devices")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(value).To(gomega.Equal("a,b,c"))
})
ginkgo.It("should return an error if the key is not recognized", func() {
_, err := pkr.Get("devicey")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("listing the query fields", func() {
ginkgo.It(
"should return the keys \"devices\", \"icon\", \"title\" in alphabetical order",
func() {
fields := pkr.QueryFields()
gomega.Expect(fields).To(gomega.Equal([]string{"devices", "icon", "title"}))
},
)
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
input := "join://Token:apikey@join?devices=dev1%2Cdev2&icon=warning&title=hey"
config := &join.Config{}
gomega.Expect(config.SetURL(testutils.URLMust(input))).To(gomega.Succeed())
gomega.Expect(config.GetURL().String()).To(gomega.Equal(input))
})
})
ginkgo.Describe("sending the payload", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
config := join.Config{
APIKey: "apikey",
Devices: []string{"dev1"},
}
serviceURL := config.GetURL()
service := join.Service{}
err = service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
"https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush",
httpmock.NewStringResponder(200, ``),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
})
func createURL(username string, token string, devices string) *url.URL {
return &url.URL{
User: url.UserPassword("Token", token),
Host: username,
RawQuery: "devices=" + devices,
}
}
func expectErrorMessageGivenURL(msg join.ErrorMessage, url *url.URL) {
err := config.SetURL(url)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.Equal(string(msg)))
}

View file

@ -0,0 +1,74 @@
package lark
import (
"fmt"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the identifier for the Lark service protocol.
const Scheme = "lark"
// Config represents the configuration for the Lark service.
type Config struct {
Host string `default:"open.larksuite.com" desc:"Custom bot URL Host" url:"Host"`
Secret string `default:"" desc:"Custom bot secret" key:"secret"`
Path string ` desc:"Custom bot token" url:"Path"`
Title string `default:"" desc:"Message Title" key:"title"`
Link string `default:"" desc:"Optional link URL" key:"link"`
}
// Enums returns a map of enum formatters (none for this service).
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}
// GetURL constructs a URL from the Config fields.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// getURL constructs a URL using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
Host: config.Host,
Path: "/" + config.Path,
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}
// SetURL updates the Config from a URL.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// setURL updates the Config from a URL using the provided resolver.
// It sets the host, path, and query parameters, validating host and path, and returns an error if parsing or validation fails.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
config.Host = url.Host
if config.Host != larkHost && config.Host != feishuHost {
return ErrInvalidHost
}
config.Path = strings.Trim(url.Path, "/")
if config.Path == "" {
return ErrNoPath
}
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting query parameter %q: %w", key, err)
}
}
return nil
}

View file

@ -0,0 +1,59 @@
package lark
// RequestBody represents the payload sent to the Lark API.
type RequestBody struct {
MsgType MsgType `json:"msg_type"`
Content Content `json:"content"`
Timestamp string `json:"timestamp,omitempty"`
Sign string `json:"sign,omitempty"`
}
// MsgType defines the type of message to send.
type MsgType string
// Constants for message types supported by Lark.
const (
MsgTypeText MsgType = "text"
MsgTypePost MsgType = "post"
)
// Content holds the message content, supporting text or post formats.
type Content struct {
Text string `json:"text,omitempty"`
Post *Post `json:"post,omitempty"`
}
// Post represents a rich post message with language-specific content.
type Post struct {
Zh *Message `json:"zh_cn,omitempty"` // Chinese content
En *Message `json:"en_us,omitempty"` // English content
}
// Message defines the structure of a post message.
type Message struct {
Title string `json:"title"`
Content [][]Item `json:"content"`
}
// Item represents a content element within a post message.
type Item struct {
Tag TagValue `json:"tag"`
Text string `json:"text,omitempty"`
Link string `json:"href,omitempty"`
}
// TagValue specifies the type of content item.
type TagValue string
// Constants for tag values supported by Lark.
const (
TagValueText TagValue = "text"
TagValueLink TagValue = "a"
)
// Response represents the API response from Lark.
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}

View file

@ -0,0 +1,237 @@
package lark
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Constants for the Lark service configuration and limits.
const (
apiFormat = "https://%s/open-apis/bot/v2/hook/%s" // API endpoint format
maxLength = 4096 // Maximum message length in bytes
defaultTime = 30 * time.Second // Default HTTP client timeout
)
const (
larkHost = "open.larksuite.com"
feishuHost = "open.feishu.cn"
)
// Error variables for the Lark service.
var (
ErrInvalidHost = errors.New("invalid host, use 'open.larksuite.com' or 'open.feishu.cn'")
ErrNoPath = errors.New(
"no path, path like 'xxx' in 'https://open.larksuite.com/open-apis/bot/v2/hook/xxx'",
)
ErrLargeMessage = errors.New("message exceeds the max length")
ErrMissingHost = errors.New("host is required but not specified in the configuration")
ErrSendFailed = errors.New("failed to send notification to Lark")
ErrInvalidSignature = errors.New("failed to generate valid signature")
)
// httpClient is configured with a default timeout.
var httpClient = &http.Client{Timeout: defaultTime}
// Service sends notifications to Lark.
type Service struct {
standard.Standard
config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Lark.
func (service *Service) Send(message string, params *types.Params) error {
if len(message) > maxLength {
return ErrLargeMessage
}
config := *service.config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating params: %w", err)
}
if config.Host != larkHost && config.Host != feishuHost {
return ErrInvalidHost
}
if config.Path == "" {
return ErrNoPath
}
return service.doSend(config, message, params)
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.config)
return service.config.SetURL(configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// doSend sends the notification to Lark using the configured API URL.
func (service *Service) doSend(config Config, message string, params *types.Params) error {
if config.Host == "" {
return ErrMissingHost
}
postURL := fmt.Sprintf(apiFormat, config.Host, config.Path)
payload, err := service.preparePayload(message, config, params)
if err != nil {
return err
}
return service.sendRequest(postURL, payload)
}
// preparePayload constructs and marshals the request payload for the Lark API.
func (service *Service) preparePayload(
message string,
config Config,
params *types.Params,
) ([]byte, error) {
body := service.getRequestBody(message, config.Title, config.Secret, params)
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshaling payload to JSON: %w", err)
}
service.Logf("Lark Request Body: %s", string(data))
return data, nil
}
// sendRequest performs the HTTP POST request to the Lark API and handles the response.
func (service *Service) sendRequest(postURL string, payload []byte) error {
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
postURL,
bytes.NewReader(payload),
)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("%w: making HTTP request: %w", ErrSendFailed, err)
}
defer resp.Body.Close()
return service.handleResponse(resp)
}
// handleResponse processes the API response and checks for errors.
func (service *Service) handleResponse(resp *http.Response) error {
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: unexpected status %s", ErrSendFailed, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body: %w", err)
}
var response Response
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("unmarshaling response: %w", err)
}
if response.Code != 0 {
return fmt.Errorf(
"%w: server returned code %d: %s",
ErrSendFailed,
response.Code,
response.Msg,
)
}
service.Logf(
"Notification sent successfully to %s/%s",
service.config.Host,
service.config.Path,
)
return nil
}
// genSign generates a signature for the request using the secret and timestamp.
func (service *Service) genSign(secret string, timestamp int64) (string, error) {
stringToSign := fmt.Sprintf("%v\n%s", timestamp, secret)
h := hmac.New(sha256.New, []byte(stringToSign))
if _, err := h.Write([]byte{}); err != nil {
return "", fmt.Errorf("%w: computing HMAC: %w", ErrInvalidSignature, err)
}
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}
// getRequestBody constructs the request body for the Lark API, supporting rich content via params.
func (service *Service) getRequestBody(
message, title, secret string,
params *types.Params,
) *RequestBody {
body := &RequestBody{}
if secret != "" {
ts := time.Now().Unix()
body.Timestamp = strconv.FormatInt(ts, 10)
sign, err := service.genSign(secret, ts)
if err != nil {
sign = "" // Fallback to empty string on error
}
body.Sign = sign
}
if title == "" {
body.MsgType = MsgTypeText
body.Content.Text = message
} else {
body.MsgType = MsgTypePost
content := [][]Item{{{Tag: TagValueText, Text: message}}}
if params != nil {
if link, ok := (*params)["link"]; ok && link != "" {
content = append(content, []Item{{Tag: TagValueLink, Text: "More Info", Link: link}})
}
}
body.Content.Post = &Post{
En: &Message{
Title: title,
Content: content,
},
}
}
return body
}

View file

@ -0,0 +1,215 @@
package lark
import (
"errors"
"log"
"net/http"
"strings"
"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/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestLark(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Lark Suite")
}
var (
service *Service
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
)
const fullURL = "lark://open.larksuite.com/token?secret=sss"
var _ = ginkgo.Describe("Lark Test", func() {
ginkgo.BeforeEach(func() {
service = &Service{}
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
url := testutils.URLMust(fullURL)
config := &Config{}
pkr := format.NewPropKeyResolver(config)
err := config.setURL(&pkr, url)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
outputURL := config.GetURL()
ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, fullURL)
gomega.Expect(outputURL.String()).To(gomega.Equal(fullURL))
})
})
ginkgo.Context("basic service API methods", func() {
var config *Config
ginkgo.BeforeEach(func() {
config = &Config{}
})
ginkgo.It("should not allow getting invalid query values", func() {
testutils.TestConfigGetInvalidQueryValue(config)
})
ginkgo.It("should not allow setting invalid query values", func() {
testutils.TestConfigSetInvalidQueryValue(
config,
"lark://endpoint/token?secret=sss&foo=bar",
)
})
ginkgo.It("should have the expected number of fields and enums", func() {
testutils.TestConfigGetEnumsCount(config, 0)
testutils.TestConfigGetFieldsCount(config, 3)
})
})
ginkgo.When("initializing the service", func() {
ginkgo.It("should fail with invalid host", func() {
err := service.Initialize(testutils.URLMust("lark://invalid.com/token"), logger)
gomega.Expect(err).To(gomega.MatchError(ErrInvalidHost))
})
ginkgo.It("should fail with no path", func() {
err := service.Initialize(testutils.URLMust("lark://open.larksuite.com"), logger)
gomega.Expect(err).To(gomega.MatchError(ErrNoPath))
})
})
ginkgo.When("sending a message", func() {
ginkgo.When("the message is too large", func() {
ginkgo.It("should return large message error", func() {
data := make([]string, 410)
for i := range data {
data[i] = "0123456789"
}
message := strings.Join(data, "")
service := Service{config: &Config{Host: larkHost, Path: "token"}}
gomega.Expect(service.Send(message, nil)).To(gomega.MatchError(ErrLargeMessage))
})
})
ginkgo.When("an invalid param is passed", func() {
ginkgo.It("should fail to send messages", func() {
service := Service{config: &Config{Host: larkHost, Path: "token"}}
gomega.Expect(
service.Send("test message", &types.Params{"invalid": "value"}),
).To(gomega.MatchError(gomega.ContainSubstring("not a valid config key: invalid")))
})
})
ginkgo.Context("sending message by HTTP", func() {
ginkgo.BeforeEach(func() {
httpmock.ActivateNonDefault(httpClient)
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should send text message successfully", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewJsonResponderOrPanic(
http.StatusOK,
map[string]any{"code": 0, "msg": "success"},
),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", nil)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
})
ginkgo.It("should send post message with title successfully", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewJsonResponderOrPanic(
http.StatusOK,
map[string]any{"code": 0, "msg": "success"},
),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", &types.Params{"title": "title"})
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
})
ginkgo.It("should send post message with link successfully", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewJsonResponderOrPanic(
http.StatusOK,
map[string]any{"code": 0, "msg": "success"},
),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send(
"message",
&types.Params{"title": "title", "link": "https://example.com"},
)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
})
ginkgo.It("should return error on network failure", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewErrorResponder(errors.New("network error")),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", nil)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("network error")))
})
ginkgo.It("should return error on invalid JSON response", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewStringResponder(http.StatusOK, "some response"),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", nil)
gomega.Expect(err).
To(gomega.MatchError(gomega.ContainSubstring("invalid character")))
})
ginkgo.It("should return error on non-zero response code", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewJsonResponderOrPanic(
http.StatusOK,
map[string]any{"code": 1, "msg": "some error"},
),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", nil)
gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("some error")))
})
ginkgo.It("should fail on HTTP 400 status", func() {
httpmock.RegisterResponder(
http.MethodPost,
"/open-apis/bot/v2/hook/token",
httpmock.NewStringResponder(http.StatusBadRequest, "bad request"),
)
err := service.Initialize(testutils.URLMust(fullURL), logger)
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
err = service.Send("message", nil)
gomega.Expect(err).
To(gomega.MatchError(gomega.ContainSubstring("unexpected status 400")))
})
})
})
})

View file

@ -0,0 +1,61 @@
package logger
import (
"fmt"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Service is the Logger service struct.
type Service struct {
standard.Standard
Config *Config
}
// Send a notification message to log.
func (service *Service) Send(message string, params *types.Params) error {
data := types.Params{}
if params != nil {
for key, value := range *params {
data[key] = value
}
}
data["message"] = message
return service.doSend(data)
}
func (service *Service) doSend(data types.Params) error {
msg := data["message"]
if tpl, found := service.GetTemplate("message"); found {
wc := &strings.Builder{}
if err := tpl.Execute(wc, data); err != nil {
return fmt.Errorf("failed to write template to log: %w", err)
}
msg = wc.String()
}
service.Log(msg)
return nil
}
// Initialize loads ServiceConfig from configURL and sets logger for this Service.
func (service *Service) Initialize(_ *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{}
return nil
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}

View file

@ -0,0 +1,30 @@
package logger
import (
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
)
const (
// Scheme is the identifying part of this service's configuration URL.
Scheme = "logger"
)
// Config is the configuration object for the Logger Service.
type Config struct {
standard.EnumlessConfig
}
// GetURL returns a URL representation of it's current field values.
func (config *Config) GetURL() *url.URL {
return &url.URL{
Scheme: Scheme,
Opaque: "//", // Ensures "logger://" output
}
}
// SetURL updates a ServiceConfig from a URL representation of it's field values.
func (config *Config) SetURL(_ *url.URL) error {
return nil
}

View file

@ -0,0 +1,107 @@
package logger_test
import (
"log"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/services/logger"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestLogger(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Logger Suite")
}
var _ = ginkgo.Describe("the logger service", func() {
ginkgo.When("sending a notification", func() {
ginkgo.It("should output the message to the log", func() {
logbuf := gbytes.NewBuffer()
service := &logger.Service{}
_ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0))
err := service.Send(`Failed - Requires Toaster Repair Level 10`, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Eventually(logbuf).
Should(gbytes.Say("Failed - Requires Toaster Repair Level 10"))
})
ginkgo.It("should not mutate the passed params", func() {
service := &logger.Service{}
_ = service.Initialize(testutils.URLMust(`logger://`), nil)
params := types.Params{}
err := service.Send(`Failed - Requires Toaster Repair Level 10`, &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(params).To(gomega.BeEmpty())
})
ginkgo.When("a template has been added", func() {
ginkgo.It("should render template with params", func() {
logbuf := gbytes.NewBuffer()
service := &logger.Service{}
_ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0))
err := service.SetTemplateString(`message`, `{{.level}}: {{.message}}`)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{
"level": "warning",
}
err = service.Send(`Requires Toaster Repair Level 10`, &params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Eventually(logbuf).
Should(gbytes.Say("warning: Requires Toaster Repair Level 10"))
})
ginkgo.It("should return an error if template execution fails", func() {
logbuf := gbytes.NewBuffer()
service := &logger.Service{}
_ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0))
err := service.SetTemplateString(
`message`,
`{{range .message}}x{{end}} {{.message}}`,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := types.Params{
"level": "error",
}
err = service.Send(`Critical Failure`, &params)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("failed to write template to log"))
})
})
})
ginkgo.Describe("the config object", func() {
ginkgo.It("should return a URL with the correct scheme from GetURL", func() {
config := &logger.Config{}
url := config.GetURL()
gomega.Expect(url.Scheme).To(gomega.Equal("logger"))
gomega.Expect(url.String()).To(gomega.Equal("logger://"))
})
ginkgo.It("should not error when SetURL is called with a valid URL", func() {
config := &logger.Config{}
url := testutils.URLMust(`logger://`)
err := config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the service identifier", func() {
ginkgo.It("should return the correct ID", func() {
service := &logger.Service{}
id := service.GetID()
gomega.Expect(id).To(gomega.Equal("logger"))
})
})
})

View file

@ -0,0 +1,79 @@
package matrix
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"
)
// Scheme identifies this service in configuration URLs.
const Scheme = "matrix"
// ErrClientNotInitialized indicates that the client is not initialized for sending messages.
var ErrClientNotInitialized = errors.New("client not initialized; cannot send message")
// Service sends notifications via the Matrix protocol.
type Service struct {
standard.Standard
Config *Config
client *client
pkr format.PropKeyResolver
}
// Initialize configures the service with a URL and logger.
func (s *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
s.SetLogger(logger)
s.Config = &Config{}
s.pkr = format.NewPropKeyResolver(s.Config)
if err := s.Config.setURL(&s.pkr, configURL); err != nil {
return err
}
if configURL.String() != "matrix://dummy@dummy.com" {
s.client = newClient(s.Config.Host, s.Config.DisableTLS, logger)
if s.Config.User != "" {
return s.client.login(s.Config.User, s.Config.Password)
}
s.client.useToken(s.Config.Password)
}
return nil
}
// GetID returns the identifier for this service.
func (s *Service) GetID() string {
return Scheme
}
// Send delivers a notification message to Matrix rooms.
func (s *Service) Send(message string, params *types.Params) error {
config := *s.Config
if err := s.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating config from params: %w", err)
}
if s.client == nil {
return ErrClientNotInitialized
}
errors := s.client.sendMessage(message, s.Config.Rooms)
if len(errors) > 0 {
for _, err := range errors {
s.Logf("error sending message: %w", err)
}
return fmt.Errorf(
"%v error(s) sending message, with initial error: %w",
len(errors),
errors[0],
)
}
return nil
}

View file

@ -0,0 +1,82 @@
package matrix
type (
messageType string
flowType string
identifierType string
)
const (
apiLogin = "/_matrix/client/r0/login"
apiRoomJoin = "/_matrix/client/r0/join/%s"
apiSendMessage = "/_matrix/client/r0/rooms/%s/send/m.room.message"
apiJoinedRooms = "/_matrix/client/r0/joined_rooms"
contentType = "application/json"
accessTokenKey = "access_token"
msgTypeText messageType = "m.text"
flowLoginPassword flowType = "m.login.password"
idTypeUser identifierType = "m.id.user"
)
type apiResLoginFlows struct {
Flows []flow `json:"flows"`
}
type apiReqLogin struct {
Type flowType `json:"type"`
Identifier *identifier `json:"identifier"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
}
type apiResLogin struct {
AccessToken string `json:"access_token"`
HomeServer string `json:"home_server"`
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
}
type apiReqSend struct {
MsgType messageType `json:"msgtype"`
Body string `json:"body"`
}
type apiResRoom struct {
RoomID string `json:"room_id"`
}
type apiResJoinedRooms struct {
Rooms []string `json:"joined_rooms"`
}
type apiResEvent struct {
EventID string `json:"event_id"`
}
type apiResError struct {
Message string `json:"error"`
Code string `json:"errcode"`
}
func (e *apiResError) Error() string {
return e.Message
}
type flow struct {
Type flowType `json:"type"`
}
type identifier struct {
Type identifierType `json:"type"`
User string `json:"user,omitempty"`
}
func newUserIdentifier(user string) *identifier {
return &identifier{
Type: idTypeUser,
User: user,
}
}

View file

@ -0,0 +1,316 @@
package matrix
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// schemeHTTPPrefixLength is the length of "http" in "https", used to strip TLS suffix.
const (
schemeHTTPPrefixLength = 4
tokenHintLength = 3
minSliceLength = 1
httpClientErrorStatus = 400
defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the timeout for HTTP requests.
)
// ErrUnsupportedLoginFlows indicates that none of the server login flows are supported.
var (
ErrUnsupportedLoginFlows = errors.New("none of the server login flows are supported")
ErrUnexpectedStatus = errors.New("unexpected HTTP status")
)
// client manages interactions with the Matrix API.
type client struct {
apiURL url.URL
accessToken string
logger types.StdLogger
httpClient *http.Client
}
// newClient creates a new Matrix client with the specified host and TLS settings.
func newClient(host string, disableTLS bool, logger types.StdLogger) *client {
client := &client{
logger: logger,
apiURL: url.URL{
Host: host,
Scheme: "https",
},
httpClient: &http.Client{
Timeout: defaultHTTPTimeout,
},
}
if client.logger == nil {
client.logger = util.DiscardLogger
}
if disableTLS {
client.apiURL.Scheme = client.apiURL.Scheme[:schemeHTTPPrefixLength] // "https" -> "http"
}
client.logger.Printf("Using server: %v\n", client.apiURL.String())
return client
}
// useToken sets the access token for the client.
func (c *client) useToken(token string) {
c.accessToken = token
c.updateAccessToken()
}
// login authenticates the client using a username and password.
func (c *client) login(user string, password string) error {
c.apiURL.RawQuery = ""
defer c.updateAccessToken()
resLogin := apiResLoginFlows{}
if err := c.apiGet(apiLogin, &resLogin); err != nil {
return fmt.Errorf("failed to get login flows: %w", err)
}
flows := make([]string, 0, len(resLogin.Flows))
for _, flow := range resLogin.Flows {
flows = append(flows, string(flow.Type))
if flow.Type == flowLoginPassword {
c.logf("Using login flow '%v'", flow.Type)
return c.loginPassword(user, password)
}
}
return fmt.Errorf("%w: %v", ErrUnsupportedLoginFlows, strings.Join(flows, ", "))
}
// loginPassword performs a password-based login to the Matrix server.
func (c *client) loginPassword(user string, password string) error {
response := apiResLogin{}
if err := c.apiPost(apiLogin, apiReqLogin{
Type: flowLoginPassword,
Password: password,
Identifier: newUserIdentifier(user),
}, &response); err != nil {
return fmt.Errorf("failed to log in: %w", err)
}
c.accessToken = response.AccessToken
tokenHint := ""
if len(response.AccessToken) > tokenHintLength {
tokenHint = response.AccessToken[:tokenHintLength]
}
c.logf("AccessToken: %v...\n", tokenHint)
c.logf("HomeServer: %v\n", response.HomeServer)
c.logf("User: %v\n", response.UserID)
return nil
}
// sendMessage sends a message to the specified rooms or all joined rooms if none are specified.
func (c *client) sendMessage(message string, rooms []string) []error {
if len(rooms) >= minSliceLength {
return c.sendToExplicitRooms(rooms, message)
}
return c.sendToJoinedRooms(message)
}
// sendToExplicitRooms sends a message to explicitly specified rooms and collects any errors.
func (c *client) sendToExplicitRooms(rooms []string, message string) []error {
var errors []error
for _, room := range rooms {
c.logf("Sending message to '%v'...\n", room)
roomID, err := c.joinRoom(room)
if err != nil {
errors = append(errors, fmt.Errorf("error joining room %v: %w", roomID, err))
continue
}
if room != roomID {
c.logf("Resolved room alias '%v' to ID '%v'", room, roomID)
}
if err := c.sendMessageToRoom(message, roomID); err != nil {
errors = append(
errors,
fmt.Errorf("failed to send message to room '%v': %w", roomID, err),
)
}
}
return errors
}
// sendToJoinedRooms sends a message to all joined rooms and collects any errors.
func (c *client) sendToJoinedRooms(message string) []error {
var errors []error
joinedRooms, err := c.getJoinedRooms()
if err != nil {
return append(errors, fmt.Errorf("failed to get joined rooms: %w", err))
}
for _, roomID := range joinedRooms {
c.logf("Sending message to '%v'...\n", roomID)
if err := c.sendMessageToRoom(message, roomID); err != nil {
errors = append(
errors,
fmt.Errorf("failed to send message to room '%v': %w", roomID, err),
)
}
}
return errors
}
// joinRoom joins a specified room and returns its ID.
func (c *client) joinRoom(room string) (string, error) {
resRoom := apiResRoom{}
if err := c.apiPost(fmt.Sprintf(apiRoomJoin, room), nil, &resRoom); err != nil {
return "", err
}
return resRoom.RoomID, nil
}
// sendMessageToRoom sends a message to a specific room.
func (c *client) sendMessageToRoom(message string, roomID string) error {
resEvent := apiResEvent{}
return c.apiPost(fmt.Sprintf(apiSendMessage, roomID), apiReqSend{
MsgType: msgTypeText,
Body: message,
}, &resEvent)
}
// apiGet performs a GET request to the Matrix API.
func (c *client) apiGet(path string, response any) error {
c.apiURL.Path = path
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL.String(), nil)
if err != nil {
return fmt.Errorf("creating GET request: %w", err)
}
res, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing GET request: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("reading GET response body: %w", err)
}
if res.StatusCode >= httpClientErrorStatus {
resError := &apiResError{}
if err = json.Unmarshal(body, resError); err == nil {
return resError
}
return fmt.Errorf("%w: %v (unmarshal error: %w)", ErrUnexpectedStatus, res.Status, err)
}
if err = json.Unmarshal(body, response); err != nil {
return fmt.Errorf("unmarshaling GET response: %w", err)
}
return nil
}
// apiPost performs a POST request to the Matrix API.
func (c *client) apiPost(path string, request any, response any) error {
c.apiURL.Path = path
body, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("marshaling POST request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.apiURL.String(),
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("creating POST request: %w", err)
}
req.Header.Set("Content-Type", contentType)
res, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing POST request: %w", err)
}
defer res.Body.Close()
body, err = io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("reading POST response body: %w", err)
}
if res.StatusCode >= httpClientErrorStatus {
resError := &apiResError{}
if err = json.Unmarshal(body, resError); err == nil {
return resError
}
return fmt.Errorf("%w: %v (unmarshal error: %w)", ErrUnexpectedStatus, res.Status, err)
}
if err = json.Unmarshal(body, response); err != nil {
return fmt.Errorf("unmarshaling POST response: %w", err)
}
return nil
}
// updateAccessToken updates the API URL query with the current access token.
func (c *client) updateAccessToken() {
query := c.apiURL.Query()
query.Set(accessTokenKey, c.accessToken)
c.apiURL.RawQuery = query.Encode()
}
// logf logs a formatted message using the client's logger.
func (c *client) logf(format string, v ...any) {
c.logger.Printf(format, v...)
}
// getJoinedRooms retrieves the list of rooms the client has joined.
func (c *client) getJoinedRooms() ([]string, error) {
response := apiResJoinedRooms{}
if err := c.apiGet(apiJoinedRooms, &response); err != nil {
return []string{}, err
}
return response.Rooms, nil
}

View file

@ -0,0 +1,68 @@
package matrix
import (
"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"
)
// Config is the configuration for the matrix service.
type Config struct {
standard.EnumlessConfig
User string `desc:"Username or empty when using access token" optional:"" url:"user"`
Password string `desc:"Password or access token" url:"password"`
DisableTLS bool ` default:"No" key:"disableTLS"`
Host string ` url:"host"`
Rooms []string `desc:"Room aliases, or with ! prefix, room IDs" optional:"" key:"rooms,room"`
Title string ` default:"" key:"title"`
}
// GetURL returns a URL representation of it's current field values.
func (c *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(c)
return c.getURL(&resolver)
}
// SetURL updates a ServiceConfig from a URL representation of it's field values.
func (c *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(c)
return c.setURL(&resolver, url)
}
func (c *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
User: url.UserPassword(c.User, c.Password),
Host: c.Host,
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}
func (c *Config) setURL(resolver types.ConfigQueryResolver, configURL *url.URL) error {
c.User = configURL.User.Username()
password, _ := configURL.User.Password()
c.Password = password
c.Host = configURL.Host
for key, vals := range configURL.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err)
}
}
for r, room := range c.Rooms {
// If room does not begin with a '#' let's prepend it
if room[0] != '#' && room[0] != '!' {
c.Rooms[r] = "#" + room
}
}
return nil
}

View file

@ -0,0 +1,676 @@
package matrix
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
)
func TestMatrix(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Matrix Suite")
}
var _ = ginkgo.Describe("the matrix service", func() {
var service *Service
logger := log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
envMatrixURL := os.Getenv("SHOUTRRR_MATRIX_URL")
ginkgo.BeforeEach(func() {
service = &Service{}
})
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (full initialization with logger and scheme)
// - 63-65: login (via Initialize when User is set)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (via Send with real server)
// - 156-173: sendMessageToRoom (sending to joined rooms)
if envMatrixURL == "" {
return
}
serviceURL, err := url.Parse(envMatrixURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("This is an integration test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("creating configurations", func() {
ginkgo.When("given an url with title prop", func() {
ginkgo.It("should not throw an error", func() {
// Tests matrix_config.go, not matrix_client.go directly
// Related to Config.SetURL, which feeds into client setup later
serviceURL := testutils.URLMust(
`matrix://user:pass@mockserver?rooms=room1&title=Better%20Off%20Alone`,
)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed())
})
})
ginkgo.When("given an url with the prop `room`", func() {
ginkgo.It("should treat is as an alias for `rooms`", func() {
// Tests matrix_config.go, not matrix_client.go directly
// Configures Rooms for client.sendToExplicitRooms later
serviceURL := testutils.URLMust(`matrix://user:pass@mockserver?room=room1`)
config := Config{}
gomega.Expect(config.SetURL(serviceURL)).To(gomega.Succeed())
gomega.Expect(config.Rooms).To(gomega.ContainElement("#room1"))
})
})
ginkgo.When("given an url with invalid props", func() {
ginkgo.It("should return an error", func() {
// Tests matrix_config.go, not matrix_client.go directly
// Ensures invalid params fail before reaching client
serviceURL := testutils.URLMust(
`matrix://user:pass@mockserver?channels=room1,room2`,
)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.HaveOccurred())
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
// Tests matrix_config.go, not matrix_client.go directly
// Verifies Config.GetURL/SetURL round-trip for client init
testURL := "matrix://user:pass@mockserver?rooms=%23room1%2C%23room2"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.Describe("the matrix client", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.When("not providing a logger", func() {
ginkgo.It("should not crash", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (sets DiscardLogger when logger is nil)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
setupMockResponders()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
gomega.Expect(service.Initialize(serviceURL, nil)).To(gomega.Succeed())
})
})
ginkgo.When("sending a message", func() {
ginkgo.It("should not report any errors", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToJoinedRooms)
// - 134-153: sendToJoinedRooms (sends to joined rooms)
// - 156-173: sendMessageToRoom (successful send)
// - 225-242: getJoinedRooms (fetches room list)
setupMockResponders()
serviceURL, _ := url.Parse("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("sending a message to explicit rooms", func() {
ginkgo.It("should not report any errors", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToExplicitRooms)
// - 112-133: sendToExplicitRooms (sends to explicit rooms)
// - 177-192: joinRoom (joins rooms successfully)
// - 156-173: sendMessageToRoom (successful send)
setupMockResponders()
serviceURL, _ := url.Parse("matrix://user:pass@mockserver?rooms=room1,room2")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.When("sending to one room fails", func() {
ginkgo.It("should report one error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToExplicitRooms)
// - 112-133: sendToExplicitRooms (handles join failure)
// - 177-192: joinRoom (fails for "secret" room)
// - 156-173: sendMessageToRoom (succeeds for "room2")
setupMockResponders()
serviceURL, _ := url.Parse("matrix://user:pass@mockserver?rooms=secret,room2")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
ginkgo.When("disabling TLS", func() {
ginkgo.It("should use HTTP instead of HTTPS", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (specifically line 50: c.apiURL.Scheme = c.apiURL.Scheme[:schemeHTTPPrefixLength])
// - 63-65: login (successful initialization over HTTP)
// - 76-87: loginPassword (successful login flow)
setupMockRespondersHTTP()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver?disableTLS=yes")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.client.apiURL.Scheme).To(gomega.Equal("http"))
})
})
ginkgo.When("failing to get login flows", func() {
ginkgo.It("should return an error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-69: login (specifically line 69: return fmt.Errorf("failed to get login flows: %w", err))
// - 175-223: apiGet (returns error due to 500 response)
setupMockRespondersLoginFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get login flows"))
})
})
ginkgo.When("no supported login flows are available", func() {
ginkgo.It("should return an error with unsupported flows", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-87: login (specifically line 84: return fmt.Errorf("none of the server login flows are supported: %v", strings.Join(flows, ", ")))
// - 175-223: apiGet (successful GET with unsupported flows)
setupMockRespondersUnsupportedFlows()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.Equal("none of the server login flows are supported: m.login.dummy"))
})
})
ginkgo.When("using a token instead of login", func() {
ginkgo.It("should initialize without errors", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 59-60: useToken (sets token and calls updateAccessToken)
// - 244-248: updateAccessToken (updates URL query with token)
setupMockResponders() // Minimal mocks for initialization
serviceURL := testutils.URLMust("matrix://:token@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.client.accessToken).To(gomega.Equal("token"))
gomega.Expect(service.client.apiURL.RawQuery).To(gomega.Equal("access_token=token"))
})
})
ginkgo.When("failing to get joined rooms", func() {
ginkgo.It("should return an error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToJoinedRooms)
// - 134-154: sendToJoinedRooms (specifically lines 137 and 154: error handling for getJoinedRooms failure)
// - 225-267: getJoinedRooms (specifically line 267: return []string{}, err)
setupMockRespondersJoinedRoomsFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get joined rooms"))
})
})
ginkgo.When("failing to join a room", func() {
ginkgo.It("should skip to the next room and continue", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToExplicitRooms)
// - 112-133: sendToExplicitRooms (specifically line 147: continue on join failure)
// - 177-192: joinRoom (specifically line 188: return "", err on failure)
// - 156-173: sendMessageToRoom (succeeds for second room)
setupMockRespondersJoinFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=secret,room2")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("error joining room"))
})
})
ginkgo.When("failing to marshal request in apiPost", func() {
ginkgo.It("should return an error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 195-252: apiPost (specifically line 208: body, err = json.Marshal(request) fails)
setupMockResponders()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.client.apiPost("/test/path", make(chan int), nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("json: unsupported type: chan int"))
})
})
ginkgo.When("failing to read response body in apiPost", func() {
ginkgo.It("should return an error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToJoinedRooms)
// - 134-153: sendToJoinedRooms (calls sendMessageToRoom)
// - 156-173: sendMessageToRoom (calls apiPost)
// - 195-252: apiPost (specifically lines 204, 223, 230: res handling and body read failure)
setupMockRespondersBodyFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("failed to read response body"))
})
})
ginkgo.When("routing to explicit rooms at line 94", func() {
ginkgo.It("should use sendToExplicitRooms", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (specifically line 94: if len(rooms) >= minSliceLength { true branch)
// - 112-133: sendToExplicitRooms (sends to explicit rooms)
// - 177-192: joinRoom (joins rooms successfully)
// - 156-173: sendMessageToRoom (successful send)
setupMockResponders()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=room1")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("routing to joined rooms at line 94", func() {
ginkgo.It("should use sendToJoinedRooms", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (specifically line 94: if len(rooms) >= minSliceLength { false branch)
// - 134-153: sendToJoinedRooms (sends to joined rooms)
// - 156-173: sendMessageToRoom (successful send)
// - 225-242: getJoinedRooms (fetches room list)
setupMockResponders()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("appending joined rooms error at line 137", func() {
ginkgo.It("should append the error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToJoinedRooms)
// - 134-154: sendToJoinedRooms (specifically line 137: errors = append(errors, fmt.Errorf("failed to get joined rooms: %w", err)))
// - 225-267: getJoinedRooms (returns error)
setupMockRespondersJoinedRoomsFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get joined rooms"))
})
})
ginkgo.When("failing to join room at line 188", func() {
ginkgo.It("should return join error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToExplicitRooms)
// - 112-133: sendToExplicitRooms (calls joinRoom)
// - 177-192: joinRoom (specifically line 188: return "", err)
setupMockRespondersJoinFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=secret")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("error joining room"))
})
})
ginkgo.When("declaring response variable at line 204", func() {
ginkgo.It("should handle HTTP failure", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 195-252: apiPost (specifically line 204: var res *http.Response and error handling)
setupMockRespondersPostFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.client.apiPost(
"/test/path",
apiReqSend{MsgType: msgTypeText, Body: "test"},
nil,
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring("simulated HTTP failure"))
})
})
ginkgo.When("marshaling request fails at line 208", func() {
ginkgo.It("should return marshal error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 195-252: apiPost (specifically line 208: body, err = json.Marshal(request))
setupMockResponders()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.client.apiPost("/test/path", make(chan int), nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("json: unsupported type: chan int"))
})
})
ginkgo.When("getting query at line 244", func() {
ginkgo.It("should update token in URL", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 59-60: useToken (calls updateAccessToken)
// - 244-248: updateAccessToken (specifically line 244: query := c.apiURL.Query())
setupMockResponders()
serviceURL := testutils.URLMust("matrix://:token@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.client.apiURL.RawQuery).To(gomega.Equal("access_token=token"))
service.client.useToken("newtoken")
gomega.Expect(service.client.apiURL.RawQuery).
To(gomega.Equal("access_token=newtoken"))
})
})
ginkgo.When("checking body read error at line 251", func() {
ginkgo.It("should return read error", func() {
// Tests matrix_client.go lines:
// - 36-52: newClient (successful setup)
// - 63-65: login (successful initialization)
// - 76-87: loginPassword (successful login flow)
// - 91-108: sendMessage (routes to sendToJoinedRooms)
// - 134-153: sendToJoinedRooms (calls sendMessageToRoom)
// - 156-173: sendMessageToRoom (calls apiPost)
// - 195-252: apiPost (specifically line 251: if err != nil { after io.ReadAll)
setupMockRespondersBodyFail()
serviceURL := testutils.URLMust("matrix://user:pass@mockserver")
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("Test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("failed to read response body"))
})
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
})
ginkgo.It("should implement basic service API methods correctly", func() {
// Tests matrix_config.go, not matrix_client.go directly
// Exercises Config methods used indirectly by client initialization
testutils.TestConfigGetInvalidQueryValue(&Config{})
testutils.TestConfigSetInvalidQueryValue(&Config{}, "matrix://user:pass@host/?foo=bar")
testutils.TestConfigGetEnumsCount(&Config{}, 0)
testutils.TestConfigGetFieldsCount(&Config{}, 4)
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("matrix"))
})
})
// setupMockResponders for HTTPS.
func setupMockResponders() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder(
"GET",
mockServer+apiJoinedRooms,
httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"),
httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "7"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "1"),
httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "8"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "2"),
httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "9"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room1"),
httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "1"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room2"),
httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "2"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23secret"),
httpmock.NewJsonResponderOrPanic(403, apiResError{
Code: "M_FORBIDDEN",
Message: "You are not invited to this room.",
}))
}
// setupMockRespondersHTTP for HTTP.
func setupMockRespondersHTTP() {
const mockServer = "http://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder(
"GET",
mockServer+apiJoinedRooms,
httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"),
httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "7"}))
}
// setupMockRespondersLoginFail for testing line 69.
func setupMockRespondersLoginFail() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(500, `{"error": "Internal Server Error"}`))
}
// setupMockRespondersUnsupportedFlows for testing line 84.
func setupMockRespondersUnsupportedFlows() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.dummy" } ] }`))
}
// setupMockRespondersJoinedRoomsFail for testing lines 137, 154, and 267.
func setupMockRespondersJoinedRoomsFail() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder(
"GET",
mockServer+apiJoinedRooms,
httpmock.NewStringResponder(500, `{"error": "Internal Server Error"}`))
}
// setupMockRespondersJoinFail for testing lines 147 and 188.
func setupMockRespondersJoinFail() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23secret"),
httpmock.NewJsonResponderOrPanic(403, apiResError{
Code: "M_FORBIDDEN",
Message: "You are not invited to this room.",
}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room2"),
httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "2"}))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "2"),
httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "9"}))
}
// setupMockRespondersBodyFail for testing lines 204, 223, and 230.
func setupMockRespondersBodyFail() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder(
"GET",
mockServer+apiJoinedRooms,
httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`))
httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"),
httpmock.NewErrorResponder(errors.New("failed to read response body")))
}
// setupMockRespondersPostFail for testing line 204 and HTTP failure.
func setupMockRespondersPostFail() {
const mockServer = "https://mockserver"
httpmock.RegisterResponder(
"GET",
mockServer+apiLogin,
httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`))
httpmock.RegisterResponder(
"POST",
mockServer+apiLogin,
httpmock.NewStringResponder(
200,
`{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`,
),
)
httpmock.RegisterResponder("POST", mockServer+"/test/path",
httpmock.NewErrorResponder(errors.New("simulated HTTP failure")))
}

View file

@ -0,0 +1,116 @@
package mattermost
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"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
// ErrSendFailed indicates that the notification failed due to an unexpected response status code.
var ErrSendFailed = errors.New(
"failed to send notification to service, response status code unexpected",
)
// Service sends notifications to a pre-configured Mattermost channel or user.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
httpClient *http.Client
}
// GetHTTPClient returns the service's HTTP client for testing purposes.
func (service *Service) GetHTTPClient() *http.Client {
return service.httpClient
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
err := service.Config.setURL(&service.pkr, configURL)
if err != nil {
return err
}
var transport *http.Transport
if service.Config.DisableTLS {
transport = &http.Transport{
TLSClientConfig: nil, // Plain HTTP
}
} else {
transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: false, // Explicitly safe when TLS is enabled
MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher
},
}
}
service.httpClient = &http.Client{Transport: transport}
return nil
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// Send delivers a notification message to Mattermost.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config
apiURL := buildURL(config)
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
return fmt.Errorf("updating config from params: %w", err)
}
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 POST request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := service.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing POST request to Mattermost API: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %s", ErrSendFailed, res.Status)
}
return nil
}
// buildURL constructs the API URL for Mattermost based on the Config.
func buildURL(config *Config) string {
scheme := "https"
if config.DisableTLS {
scheme = "http"
}
return fmt.Sprintf("%s://%s/hooks/%s", scheme, config.Host, config.Token)
}

View file

@ -0,0 +1,121 @@
package mattermost
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 identifying part of this service's configuration URL.
const Scheme = "mattermost"
// Static errors for configuration validation.
var (
ErrNotEnoughArguments = errors.New(
"the apiURL does not include enough arguments, either provide 1 or 3 arguments (they may be empty)",
)
)
// ErrorMessage represents error events within the Mattermost service.
type ErrorMessage string
// Config holds all configuration information for the Mattermost service.
type Config struct {
standard.EnumlessConfig
UserName string `desc:"Override webhook user" optional:"" url:"user"`
Icon string `desc:"Use emoji or URL as icon (based on presence of http(s):// prefix)" optional:"" default:"" key:"icon,icon_emoji,icon_url"`
Title string `desc:"Notification title, optionally set by the sender (not used)" default:"" key:"title"`
Channel string `desc:"Override webhook channel" optional:"" url:"path2"`
Host string `desc:"Mattermost server host" url:"host,port"`
Token string `desc:"Webhook token" url:"path1"`
DisableTLS bool ` default:"No" key:"disabletls"`
}
// CreateConfigFromURL creates a new Config instance from a URL representation.
func CreateConfigFromURL(url *url.URL) (*Config, error) {
config := &Config{}
if err := config.SetURL(url); err != nil {
return nil, err
}
return config, nil
}
// GetURL returns a URL representation of the Config's current field values.
func (c *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(c)
return c.getURL(&resolver) // Pass pointer to resolver
}
// SetURL updates the Config from a URL representation of its field values.
func (c *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(c)
return c.setURL(&resolver, url) // Pass pointer to resolver
}
// getURL constructs a URL from the Config's fields using the provided resolver.
func (c *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
paths := []string{"", c.Token, c.Channel}
if c.Channel == "" {
paths = paths[:2]
}
var user *url.Userinfo
if c.UserName != "" {
user = url.User(c.UserName)
}
return &url.URL{
User: user,
Host: c.Host,
Path: strings.Join(paths, "/"),
Scheme: Scheme,
ForceQuery: false,
RawQuery: format.BuildQuery(resolver),
}
}
// setURL updates the Config from a URL using the provided resolver.
func (c *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
c.Host = url.Host
c.UserName = url.User.Username()
if err := c.parsePath(url); err != nil {
return err
}
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
}
// parsePath extracts Token and Channel from the URL path and validates arguments.
func (c *Config) parsePath(url *url.URL) error {
path := strings.Split(strings.Trim(url.Path, "/"), "/")
isDummy := url.String() == "mattermost://dummy@dummy.com"
if !isDummy && (len(path) < 1 || path[0] == "") {
return ErrNotEnoughArguments
}
if len(path) > 0 && path[0] != "" {
c.Token = path[0]
}
if len(path) > 1 && path[1] != "" {
c.Channel = path[1]
}
return nil
}

View file

@ -0,0 +1,63 @@
package mattermost
import (
"encoding/json"
"fmt" // Add this import
"regexp"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// iconURLPattern matches URLs starting with http or https for icon detection.
var iconURLPattern = regexp.MustCompile(`https?://`)
// JSON represents the payload structure for Mattermost notifications.
type JSON struct {
Text string `json:"text"`
UserName string `json:"username,omitempty"`
Channel string `json:"channel,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
IconURL string `json:"icon_url,omitempty"`
}
// SetIcon sets the appropriate icon field in the payload based on whether the input is a URL or not.
func (j *JSON) SetIcon(icon string) {
j.IconURL = ""
j.IconEmoji = ""
if icon != "" {
if iconURLPattern.MatchString(icon) {
j.IconURL = icon
} else {
j.IconEmoji = icon
}
}
}
// CreateJSONPayload generates a JSON payload for the Mattermost service.
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
}
}
payload.SetIcon(config.Icon)
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshaling Mattermost payload to JSON: %w", err)
}
return payloadBytes, nil
}

View file

@ -0,0 +1,440 @@
package mattermost
import (
"fmt"
"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/types"
)
var (
service *Service
envMattermostURL *url.URL
_ = ginkgo.BeforeSuite(func() {
service = &Service{}
envMattermostURL, _ = url.Parse(os.Getenv("SHOUTRRR_MATTERMOST_URL"))
})
)
func TestMattermost(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Mattermost Suite")
}
var _ = ginkgo.Describe("the mattermost service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should work without errors", func() {
if envMattermostURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envMattermostURL.String())
gomega.Expect(service.Initialize(serviceURL, testutils.TestLogger())).
To(gomega.Succeed())
err := service.Send(
"this is an integration test",
nil,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the mattermost config", func() {
ginkgo.When("generating a config object", func() {
mattermostURL, _ := url.Parse(
"mattermost://mattermost.my-domain.com/thisshouldbeanapitoken",
)
config := &Config{}
err := config.SetURL(mattermostURL)
ginkgo.It("should not have caused an error", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set host", func() {
gomega.Expect(config.Host).To(gomega.Equal("mattermost.my-domain.com"))
})
ginkgo.It("should set token", func() {
gomega.Expect(config.Token).To(gomega.Equal("thisshouldbeanapitoken"))
})
ginkgo.It("should not set channel or username", func() {
gomega.Expect(config.Channel).To(gomega.BeEmpty())
gomega.Expect(config.UserName).To(gomega.BeEmpty())
})
})
ginkgo.When("generating a new config with url, that has no token", func() {
ginkgo.It("should return an error", func() {
mattermostURL, _ := url.Parse("mattermost://mattermost.my-domain.com")
config := &Config{}
err := config.SetURL(mattermostURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("generating a config object with username only", func() {
mattermostURL, _ := url.Parse(
"mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken",
)
config := &Config{}
err := config.SetURL(mattermostURL)
ginkgo.It("should not have caused an error", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set username", func() {
gomega.Expect(config.UserName).To(gomega.Equal("testUserName"))
})
ginkgo.It("should not set channel", func() {
gomega.Expect(config.Channel).To(gomega.BeEmpty())
})
})
ginkgo.When("generating a config object with channel only", func() {
mattermostURL, _ := url.Parse(
"mattermost://mattermost.my-domain.com/thisshouldbeanapitoken/testChannel",
)
config := &Config{}
err := config.SetURL(mattermostURL)
ginkgo.It("should not hav caused an error", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set channel", func() {
gomega.Expect(config.Channel).To(gomega.Equal("testChannel"))
})
ginkgo.It("should not set username", func() {
gomega.Expect(config.UserName).To(gomega.BeEmpty())
})
})
ginkgo.When("generating a config object with channel an userName", func() {
mattermostURL, _ := url.Parse(
"mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel",
)
config := &Config{}
err := config.SetURL(mattermostURL)
ginkgo.It("should not hav caused an error", func() {
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should set channel", func() {
gomega.Expect(config.Channel).To(gomega.Equal("testChannel"))
})
ginkgo.It("should set username", func() {
gomega.Expect(config.UserName).To(gomega.Equal("testUserName"))
})
})
ginkgo.When("using DisableTLS and port", func() {
mattermostURL, _ := url.Parse(
"mattermost://watchtower@home.lan:8065/token/channel?disabletls=yes",
)
config := &Config{}
gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed())
ginkgo.It("should preserve host with port", func() {
gomega.Expect(config.Host).To(gomega.Equal("home.lan:8065"))
})
ginkgo.It("should set DisableTLS", func() {
gomega.Expect(config.DisableTLS).To(gomega.BeTrue())
})
ginkgo.It("should generate http URL", func() {
gomega.Expect(buildURL(config)).To(gomega.Equal("http://home.lan:8065/hooks/token"))
})
ginkgo.It("should serialize back correctly", func() {
gomega.Expect(config.GetURL().String()).
To(gomega.Equal("mattermost://watchtower@home.lan:8065/token/channel?disabletls=Yes"))
})
})
ginkgo.Describe("initializing with DisableTLS", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should use plain HTTP transport when DisableTLS is true", func() {
mattermostURL, _ := url.Parse("mattermost://user@host:8080/token?disabletls=yes")
service := &Service{}
err := service.Initialize(mattermostURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.ActivateNonDefault(service.httpClient)
httpmock.RegisterResponder(
"POST",
"http://host:8080/hooks/token",
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(buildURL(service.Config)).
To(gomega.Equal("http://host:8080/hooks/token"))
})
})
ginkgo.Describe("sending the payload", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
config := Config{
Host: "mattermost.host",
Token: "token",
}
serviceURL := config.GetURL()
service := Service{}
err = service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.ActivateNonDefault(service.httpClient)
httpmock.RegisterResponder(
"POST",
"https://mattermost.host/hooks/token",
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should return an error if the server rejects the payload", func() {
config := Config{
Host: "mattermost.host",
Token: "token",
}
serviceURL := config.GetURL()
service := Service{}
err = service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.ActivateNonDefault(service.httpClient)
httpmock.RegisterResponder(
"POST",
"https://mattermost.host/hooks/token",
httpmock.NewStringResponder(403, "Forbidden"),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).
To(gomega.ContainSubstring("failed to send notification to service"))
resp := httpmock.NewStringResponse(403, "Forbidden")
resp.Status = "403 Forbidden"
httpmock.RegisterResponder(
"POST",
"https://mattermost.host/hooks/token",
httpmock.ResponderFromResponse(resp),
)
})
})
})
ginkgo.When("generating a config object", func() {
ginkgo.It("should not set icon", func() {
slackURL, _ := url.Parse("mattermost://AAAAAAAAA/BBBBBBBBB")
config, configError := CreateConfigFromURL(slackURL)
gomega.Expect(configError).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Icon).To(gomega.BeEmpty())
})
ginkgo.It("should set icon", func() {
slackURL, _ := url.Parse("mattermost://AAAAAAAAA/BBBBBBBBB?icon=test")
config, configError := CreateConfigFromURL(slackURL)
gomega.Expect(configError).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Icon).To(gomega.BeIdenticalTo("test"))
})
})
ginkgo.Describe("creating the payload", func() {
ginkgo.Describe("the icon fields", func() {
payload := JSON{}
ginkgo.It("should set IconURL when the configured icon looks like an URL", func() {
payload.SetIcon("https://example.com/logo.png")
gomega.Expect(payload.IconURL).To(gomega.Equal("https://example.com/logo.png"))
gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty())
})
ginkgo.It(
"should set IconEmoji when the configured icon does not look like an URL",
func() {
payload.SetIcon("tanabata_tree")
gomega.Expect(payload.IconEmoji).To(gomega.Equal("tanabata_tree"))
gomega.Expect(payload.IconURL).To(gomega.BeEmpty())
},
)
ginkgo.It("should clear both fields when icon is empty", func() {
payload.SetIcon("")
gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty())
gomega.Expect(payload.IconURL).To(gomega.BeEmpty())
})
})
})
ginkgo.Describe("Sending messages", func() {
ginkgo.When("sending a message completely without parameters", func() {
mattermostURL, _ := url.Parse(
"mattermost://mattermost.my-domain.com/thisshouldbeanapitoken",
)
config := &Config{}
gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed())
ginkgo.It("should generate the correct url to call", func() {
generatedURL := buildURL(config)
gomega.Expect(generatedURL).
To(gomega.Equal("https://mattermost.my-domain.com/hooks/thisshouldbeanapitoken"))
})
ginkgo.It("should generate the correct JSON body", func() {
json, err := CreateJSONPayload(config, "this is a message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(json)).To(gomega.Equal("{\"text\":\"this is a message\"}"))
})
})
ginkgo.When("sending a message with pre set username and channel", func() {
mattermostURL, _ := url.Parse(
"mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel",
)
config := &Config{}
gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed())
ginkgo.It("should generate the correct JSON body", func() {
json, err := CreateJSONPayload(config, "this is a message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(json)).
To(gomega.Equal("{\"text\":\"this is a message\",\"username\":\"testUserName\",\"channel\":\"testChannel\"}"))
})
})
ginkgo.When(
"sending a message with pre set username and channel but overwriting them with parameters",
func() {
mattermostURL, _ := url.Parse(
"mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel",
)
config := &Config{}
gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed())
ginkgo.It("should generate the correct JSON body", func() {
params := (*types.Params)(
&map[string]string{
"username": "overwriteUserName",
"channel": "overwriteChannel",
},
)
json, err := CreateJSONPayload(config, "this is a message", params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(string(json)).
To(gomega.Equal("{\"text\":\"this is a message\",\"username\":\"overwriteUserName\",\"channel\":\"overwriteChannel\"}"))
})
},
)
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
input := "mattermost://bot@mattermost.host/token/channel"
config := &Config{}
gomega.Expect(config.SetURL(testutils.URLMust(input))).To(gomega.Succeed())
gomega.Expect(config.GetURL().String()).To(gomega.Equal(input))
})
})
ginkgo.Describe("creating configurations", func() {
ginkgo.When("given a url with channel field", func() {
ginkgo.It("should not throw an error", func() {
serviceURL := testutils.URLMust(`mattermost://user@mockserver/atoken/achannel`)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed())
})
})
ginkgo.When("given a url with title prop", func() {
ginkgo.It("should not throw an error", func() {
serviceURL := testutils.URLMust(
`mattermost://user@mockserver/atoken?icon=https%3A%2F%2Fexample%2Fsomething.png`,
)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed())
})
})
ginkgo.When("given a url with all fields and props", func() {
ginkgo.It("should not throw an error", func() {
serviceURL := testutils.URLMust(
`mattermost://user@mockserver/atoken/achannel?icon=https%3A%2F%2Fexample%2Fsomething.png`,
)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed())
})
})
ginkgo.When("given a url with invalid props", func() {
ginkgo.It("should return an error", func() {
serviceURL := testutils.URLMust(`matrix://user@mockserver/atoken?foo=bar`)
gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.HaveOccurred())
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "mattermost://user@mockserver/atoken/achannel?icon=something"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
fmt.Fprint(ginkgo.GinkgoWriter, outputURL.String(), " ", testURL, "\n")
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.Describe("sending the payload", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
config := Config{
Host: "mattermost.host",
Token: "token",
}
serviceURL := config.GetURL()
service := Service{}
err = service.Initialize(serviceURL, nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.ActivateNonDefault(service.httpClient)
httpmock.RegisterResponder(
"POST",
"https://mattermost.host/hooks/token",
httpmock.NewStringResponder(200, ``),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.Describe("the basic service API", func() {
ginkgo.Describe("the service config", func() {
ginkgo.It("should implement basic service config API methods correctly", func() {
testutils.TestConfigGetInvalidQueryValue(&Config{})
testutils.TestConfigSetDefaultValues(&Config{})
testutils.TestConfigGetEnumsCount(&Config{}, 0)
testutils.TestConfigGetFieldsCount(&Config{}, 5)
})
})
ginkgo.Describe("the service instance", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should implement basic service API methods correctly", func() {
serviceURL := testutils.URLMust("mattermost://mockhost/mocktoken")
gomega.Expect(service.Initialize(serviceURL, testutils.TestLogger())).
To(gomega.Succeed())
testutils.TestServiceSetInvalidParamValue(service, "foo", "bar")
})
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("mattermost"))
})
})

99
pkg/services/ntfy/ntfy.go Normal file
View file

@ -0,0 +1,99 @@
package ntfy
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/internal/meta"
"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"
)
// Service sends notifications to Ntfy.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Ntfy.
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)
}
if err := service.sendAPI(config, message); err != nil {
return fmt.Errorf("failed to send ntfy notification: %w", err)
}
return nil
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
_ = service.pkr.SetDefaultProps(service.Config)
return service.Config.setURL(&service.pkr, configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// sendAPI sends a notification to the Ntfy API.
func (service *Service) sendAPI(config *Config, message string) error {
response := apiResponse{}
request := message
jsonClient := jsonclient.NewClient()
headers := jsonClient.Headers()
headers.Del("Content-Type")
headers.Set("User-Agent", "shoutrrr/"+meta.Version)
addHeaderIfNotEmpty(&headers, "Title", config.Title)
addHeaderIfNotEmpty(&headers, "Priority", config.Priority.String())
addHeaderIfNotEmpty(&headers, "Tags", strings.Join(config.Tags, ","))
addHeaderIfNotEmpty(&headers, "Delay", config.Delay)
addHeaderIfNotEmpty(&headers, "Actions", strings.Join(config.Actions, ";"))
addHeaderIfNotEmpty(&headers, "Click", config.Click)
addHeaderIfNotEmpty(&headers, "Attach", config.Attach)
addHeaderIfNotEmpty(&headers, "X-Icon", config.Icon)
addHeaderIfNotEmpty(&headers, "Filename", config.Filename)
addHeaderIfNotEmpty(&headers, "Email", config.Email)
if !config.Cache {
headers.Add("Cache", "no")
}
if !config.Firebase {
headers.Add("Firebase", "no")
}
if err := jsonClient.Post(config.GetAPIURL(), request, &response); err != nil {
if jsonClient.ErrorResponse(err, &response) {
// apiResponse implements Error
return &response
}
return fmt.Errorf("posting to Ntfy API: %w", err)
}
return nil
}
// addHeaderIfNotEmpty adds a header to the request if the value is non-empty.
func addHeaderIfNotEmpty(headers *http.Header, key string, value string) {
if value != "" {
headers.Add(key, value)
}
}

View file

@ -0,0 +1,119 @@
package ntfy
import (
"errors"
"fmt" // Add this import
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the identifying part of this service's configuration URL.
const (
Scheme = "ntfy"
)
// ErrTopicRequired indicates that the topic is missing from the config URL.
var ErrTopicRequired = errors.New("topic is required")
// Config holds the configuration for the Ntfy service.
type Config struct {
Title string `default:"" desc:"Message title" key:"title"`
Host string `default:"ntfy.sh" desc:"Server hostname and port" url:"host"`
Topic string ` desc:"Target topic name" url:"path" required:""`
Password string ` desc:"Auth password" url:"password" optional:""`
Username string ` desc:"Auth username" url:"user" optional:""`
Scheme string `default:"https" desc:"Server protocol, http or https" key:"scheme"`
Tags []string ` desc:"List of tags that may or not map to emojis" key:"tags" optional:""`
Priority priority `default:"default" desc:"Message priority with 1=min, 3=default and 5=max" key:"priority"`
Actions []string ` desc:"Custom user action buttons for notifications, see https://docs.ntfy.sh/publish/#action-buttons" key:"actions" optional:"" sep:";"`
Click string ` desc:"Website opened when notification is clicked" key:"click" optional:""`
Attach string ` desc:"URL of an attachment, see attach via URL" key:"attach" optional:""`
Filename string ` desc:"File name of the attachment" key:"filename" optional:""`
Delay string ` desc:"Timestamp or duration for delayed delivery, see https://docs.ntfy.sh/publish/#scheduled-delivery" key:"delay,at,in" optional:""`
Email string ` desc:"E-mail address for e-mail notifications" key:"email" optional:""`
Icon string ` desc:"URL to use as notification icon" key:"icon" optional:""`
Cache bool `default:"yes" desc:"Cache messages" key:"cache"`
Firebase bool `default:"yes" desc:"Send to firebase" key:"firebase"`
}
// Enums returns the fields that use an EnumFormatter for their values.
func (*Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{
"Priority": Priority.Enum,
}
}
// 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)
}
// GetAPIURL constructs the API URL for the Ntfy service based on the configuration.
func (config *Config) GetAPIURL() string {
path := config.Topic
if !strings.HasPrefix(config.Topic, "/") {
path = "/" + path
}
var creds *url.Userinfo
if config.Password != "" {
creds = url.UserPassword(config.Username, config.Password)
}
apiURL := url.URL{
Scheme: config.Scheme,
Host: config.Host,
Path: path,
User: creds,
}
return apiURL.String()
}
// 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{
User: url.UserPassword(config.Username, config.Password),
Host: config.Host,
Scheme: Scheme,
ForceQuery: true,
Path: config.Topic,
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 {
password, _ := url.User.Password()
config.Password = password
config.Username = url.User.Username()
config.Host = url.Host
config.Topic = strings.TrimPrefix(url.Path, "/")
url.RawQuery = strings.ReplaceAll(url.RawQuery, ";", "%3b")
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)
}
}
if url.String() != "ntfy://dummy@dummy.com" {
if config.Topic == "" {
return ErrTopicRequired
}
}
return nil
}

View file

@ -0,0 +1,19 @@
package ntfy
import "fmt"
//nolint:errname
type apiResponse struct {
Code int64 `json:"code"`
Message string `json:"error"`
Link string `json:"link"`
}
func (e *apiResponse) Error() string {
msg := fmt.Sprintf("server response: %v (%v)", e.Message, e.Code)
if e.Link != "" {
return msg + ", see: " + e.Link
}
return msg
}

View file

@ -0,0 +1,55 @@
package ntfy
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Priority levels as constants.
const (
PriorityMin priority = 1
PriorityLow priority = 2
PriorityDefault priority = 3
PriorityHigh priority = 4
PriorityMax priority = 5
)
// Priority defines the notification priority levels.
var Priority = &priorityVals{
Min: PriorityMin,
Low: PriorityLow,
Default: PriorityDefault,
High: PriorityHigh,
Max: PriorityMax,
Enum: format.CreateEnumFormatter(
[]string{
"",
"Min",
"Low",
"Default",
"High",
"Max",
}, map[string]int{
"1": int(PriorityMin),
"2": int(PriorityLow),
"3": int(PriorityDefault),
"4": int(PriorityHigh),
"5": int(PriorityMax),
"urgent": int(PriorityMax),
}),
}
type priority int
type priorityVals struct {
Min priority
Low priority
Default priority
High priority
Max priority
Enum types.EnumFormatter
}
func (p priority) String() string {
return Priority.Enum.Print(int(p))
}

View file

@ -0,0 +1,162 @@
package ntfy
import (
"log"
"net/http"
"net/url"
"os"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
gomegaformat "github.com/onsi/gomega/format"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
)
func TestNtfy(t *testing.T) {
gomegaformat.CharactersAroundMismatchToInclude = 20
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Ntfy Suite")
}
var (
service = &Service{}
envBarkURL *url.URL
logger *log.Logger = testutils.TestLogger()
_ = ginkgo.BeforeSuite(func() {
envBarkURL, _ = url.Parse(os.Getenv("SHOUTRRR_NTFY_URL"))
})
)
var _ = ginkgo.Describe("the ntfy service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
if envBarkURL.String() == "" {
ginkgo.Skip("No integration test ENV URL was set")
return
}
configURL := testutils.URLMust(envBarkURL.String())
gomega.Expect(service.Initialize(configURL, logger)).To(gomega.Succeed())
gomega.Expect(service.Send("This is an integration test message", nil)).
To(gomega.Succeed())
})
})
ginkgo.Describe("the config", func() {
ginkgo.When("getting a API URL", func() {
ginkgo.It("should return the expected URL", func() {
gomega.Expect((&Config{
Host: "host:8080",
Scheme: "http",
Topic: "topic",
}).GetAPIURL()).To(gomega.Equal("http://host:8080/topic"))
})
})
ginkgo.When("only required fields are set", func() {
ginkgo.It("should set the optional fields to the defaults", func() {
serviceURL := testutils.URLMust("ntfy://hostname/topic")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
gomega.Expect(*service.Config).To(gomega.Equal(Config{
Host: "hostname",
Topic: "topic",
Scheme: "https",
Tags: []string{""},
Actions: []string{""},
Priority: 3,
Firebase: true,
Cache: true,
}))
})
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "ntfy://user:pass@example.com:2225/topic?cache=No&click=CLICK&firebase=No&icon=ICON&priority=Max&scheme=http&title=TITLE"
config := &Config{}
pkr := format.NewPropKeyResolver(config)
gomega.Expect(config.setURL(&pkr, testutils.URLMust(testURL))).
To(gomega.Succeed(), "verifying")
gomega.Expect(config.GetURL().String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.When("sending the push payload", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
httpmock.RegisterResponder(
"POST",
service.Config.GetAPIURL(),
testutils.JSONRespondMust(200, apiResponse{
Code: http.StatusOK,
Message: "OK",
}),
)
gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed())
})
ginkgo.It("should not panic if a server error occurs", func() {
serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
httpmock.RegisterResponder(
"POST",
service.Config.GetAPIURL(),
testutils.JSONRespondMust(500, apiResponse{
Code: 500,
Message: "someone turned off the internet",
}),
)
gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred())
})
ginkgo.It("should not panic if a communication error occurs", func() {
httpmock.DeactivateAndReset()
serviceURL := testutils.URLMust("ntfy://:devicekey@nonresolvablehostname/testtopic")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("the basic service API", func() {
ginkgo.Describe("the service config", func() {
ginkgo.It("should implement basic service config API methods correctly", func() {
testutils.TestConfigGetInvalidQueryValue(&Config{})
testutils.TestConfigSetInvalidQueryValue(&Config{}, "ntfy://host/topic?foo=bar")
testutils.TestConfigSetDefaultValues(&Config{})
testutils.TestConfigGetEnumsCount(&Config{}, 1)
testutils.TestConfigGetFieldsCount(&Config{}, 15)
})
})
ginkgo.Describe("the service instance", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should implement basic service API methods correctly", func() {
serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic")
gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed())
testutils.TestServiceSetInvalidParamValue(service, "foo", "bar")
})
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("ntfy"))
})
})

View file

@ -0,0 +1,160 @@
package opsgenie
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// alertEndpointTemplate is the OpsGenie API endpoint template for sending alerts.
const (
alertEndpointTemplate = "https://%s:%d/v2/alerts"
MaxMessageLength = 130 // MaxMessageLength is the maximum length of the alert message field in OpsGenie.
httpSuccessMax = 299 // httpSuccessMax is the maximum HTTP status code for a successful response.
defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests.
)
// ErrUnexpectedStatus indicates that OpsGenie returned an unexpected HTTP status code.
var ErrUnexpectedStatus = errors.New("OpsGenie notification returned unexpected HTTP status code")
// Service provides OpsGenie as a notification service.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// sendAlert sends an alert to OpsGenie using the specified URL and API key.
func (service *Service) sendAlert(url string, apiKey string, payload AlertPayload) error {
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshaling alert payload to JSON: %w", err)
}
jsonBuffer := bytes.NewBuffer(jsonBody)
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, jsonBuffer)
if err != nil {
return fmt.Errorf("creating HTTP request: %w", err)
}
req.Header.Add("Authorization", "GenieKey "+apiKey)
req.Header.Add("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send notification to OpsGenie: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode > httpSuccessMax {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf(
"%w: %d, cannot read body: %w",
ErrUnexpectedStatus,
resp.StatusCode,
err,
)
}
return fmt.Errorf("%w: %d - %s", ErrUnexpectedStatus, resp.StatusCode, body)
}
return nil
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
return service.Config.setURL(&service.pkr, configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// Send delivers a notification message to OpsGenie.
// See: https://docs.opsgenie.com/docs/alert-api#create-alert
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config
endpointURL := fmt.Sprintf(alertEndpointTemplate, config.Host, config.Port)
payload, err := service.newAlertPayload(message, params)
if err != nil {
return err
}
return service.sendAlert(endpointURL, config.APIKey, payload)
}
// newAlertPayload creates a new alert payload for OpsGenie based on the message and parameters.
func (service *Service) newAlertPayload(
message string,
params *types.Params,
) (AlertPayload, error) {
if params == nil {
params = &types.Params{}
}
// Defensive copy
payloadFields := *service.Config
if err := service.pkr.UpdateConfigFromParams(&payloadFields, params); err != nil {
return AlertPayload{}, fmt.Errorf("updating payload fields from params: %w", err)
}
// Use `Message` for the title if available, or if the message is too long
// Use `Description` for the message in these scenarios
title := payloadFields.Title
description := message
if title == "" {
if len(message) > MaxMessageLength {
title = message[:MaxMessageLength]
} else {
title = message
description = ""
}
}
if payloadFields.Description != "" && description != "" {
description += "\n"
}
result := AlertPayload{
Message: title,
Alias: payloadFields.Alias,
Description: description + payloadFields.Description,
Responders: payloadFields.Responders,
VisibleTo: payloadFields.VisibleTo,
Actions: payloadFields.Actions,
Tags: payloadFields.Tags,
Details: payloadFields.Details,
Entity: payloadFields.Entity,
Source: payloadFields.Source,
Priority: payloadFields.Priority,
User: payloadFields.User,
Note: payloadFields.Note,
}
return result, nil
}

View file

@ -0,0 +1,109 @@
package opsgenie
import (
"errors"
"fmt"
"net/url"
"strconv"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
defaultPort = 443 // defaultPort is the default port for OpsGenie API connections.
Scheme = "opsgenie" // Scheme is the identifying part of this service's configuration URL.
)
// ErrAPIKeyMissing indicates that the API key is missing from the config URL path.
var ErrAPIKeyMissing = errors.New("API key missing from config URL path")
// Config holds the configuration for the OpsGenie service.
type Config struct {
APIKey string `desc:"The OpsGenie API key" url:"path"`
Host string `desc:"The OpsGenie API host. Use 'api.eu.opsgenie.com' for EU instances" url:"host" default:"api.opsgenie.com"`
Port uint16 `desc:"The OpsGenie API port." url:"port" default:"443"`
Alias string `desc:"Client-defined identifier of the alert" key:"alias" optional:"true"`
Description string `desc:"Description field of the alert" key:"description" optional:"true"`
Responders []Entity `desc:"Teams, users, escalations and schedules that the alert will be routed to send notifications" key:"responders" optional:"true"`
VisibleTo []Entity `desc:"Teams and users that the alert will become visible to without sending any notification" key:"visibleTo" optional:"true"`
Actions []string `desc:"Custom actions that will be available for the alert" key:"actions" optional:"true"`
Tags []string `desc:"Tags of the alert" key:"tags" optional:"true"`
Details map[string]string `desc:"Map of key-value pairs to use as custom properties of the alert" key:"details" optional:"true"`
Entity string `desc:"Entity field of the alert that is generally used to specify which domain the Source field of the alert" key:"entity" optional:"true"`
Source string `desc:"Source field of the alert" key:"source" optional:"true"`
Priority string `desc:"Priority level of the alert. Possible values are P1, P2, P3, P4 and P5" key:"priority" optional:"true"`
Note string `desc:"Additional note that will be added while creating the alert" key:"note" optional:"true"`
User string `desc:"Display name of the request owner" key:"user" optional:"true"`
Title string `desc:"notification title, optionally set by the sender" default:"" key:"title"`
}
// Enums returns an empty map because the OpsGenie service doesn't use Enums.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}
// 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)
}
// getURL constructs a URL from the Config's fields using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
var host string
if config.Port > 0 {
host = fmt.Sprintf("%s:%d", config.Host, config.Port)
} else {
host = config.Host
}
result := &url.URL{
Host: host,
Path: "/" + config.APIKey,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
}
return result
}
// 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)
}
// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
config.Host = url.Hostname()
if url.String() != "opsgenie://dummy@dummy.com" {
if len(url.Path) > 0 {
config.APIKey = url.Path[1:]
} else {
return ErrAPIKeyMissing
}
}
if url.Port() != "" {
port, err := strconv.ParseUint(url.Port(), 10, 16)
if err != nil {
return fmt.Errorf("parsing port %q: %w", url.Port(), err)
}
config.Port = uint16(port)
} else {
config.Port = defaultPort
}
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
}

View file

@ -0,0 +1,93 @@
package opsgenie
import (
"errors"
"fmt"
"regexp"
"strings"
)
// EntityPartsCount is the expected number of parts in an entity string (type:identifier).
const (
EntityPartsCount = 2 // Expected number of parts in an entity string (type:identifier)
)
// ErrInvalidEntityFormat indicates that the entity string does not have two elements separated by a colon.
var (
ErrInvalidEntityFormat = errors.New(
"invalid entity, should have two elements separated by colon",
)
ErrInvalidEntityIDName = errors.New("invalid entity, cannot parse id/name")
ErrUnexpectedEntityType = errors.New("invalid entity, unexpected entity type")
ErrMissingEntityIdentity = errors.New("invalid entity, should have either ID, name or username")
)
// Entity represents an OpsGenie entity (e.g., user, team) with type and identifier.
// Example JSON: { "username":"trinity@opsgenie.com", "type":"user" }.
type Entity struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
}
// SetFromProp deserializes an entity from a string in the format "type:identifier".
func (e *Entity) SetFromProp(propValue string) error {
elements := strings.Split(propValue, ":")
if len(elements) != EntityPartsCount {
return fmt.Errorf("%w: %q", ErrInvalidEntityFormat, propValue)
}
e.Type = elements[0]
identifier := elements[1]
isID, err := isOpsGenieID(identifier)
if err != nil {
return fmt.Errorf("%w: %q", ErrInvalidEntityIDName, identifier)
}
switch {
case isID:
e.ID = identifier
case e.Type == "team":
e.Name = identifier
case e.Type == "user":
e.Username = identifier
default:
return fmt.Errorf("%w: %q", ErrUnexpectedEntityType, e.Type)
}
return nil
}
// GetPropValue serializes an entity back into a string in the format "type:identifier".
func (e *Entity) GetPropValue() (string, error) {
var identifier string
switch {
case e.ID != "":
identifier = e.ID
case e.Name != "":
identifier = e.Name
case e.Username != "":
identifier = e.Username
default:
return "", ErrMissingEntityIdentity
}
return fmt.Sprintf("%s:%s", e.Type, identifier), nil
}
// isOpsGenieID checks if a string matches the OpsGenie ID format (e.g., 4513b7ea-3b91-438f-b7e4-e3e54af9147c).
func isOpsGenieID(str string) (bool, error) {
matched, err := regexp.MatchString(
`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
str,
)
if err != nil {
return false, fmt.Errorf("matching OpsGenie ID format for %q: %w", str, err)
}
return matched, nil
}

View file

@ -0,0 +1,33 @@
package opsgenie
// AlertPayload represents the payload being sent to the OpsGenie API
//
// See: https://docs.opsgenie.com/docs/alert-api#create-alert
//
// Some fields contain complex values like arrays and objects.
// Because `params` are strings only we cannot pass in slices
// or maps. Instead we "preserve" the JSON in those fields. That
// way we can pass in complex types as JSON like so:
//
// service.Send("An example alert message", &types.Params{
// "alias": "Life is too short for no alias",
// "description": "Every alert needs a description",
// "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`,
// "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`,
// "details": `{"key1": "value1", "key2": "value2"}`,
// })
type AlertPayload struct {
Message string `json:"message"`
Alias string `json:"alias,omitempty"`
Description string `json:"description,omitempty"`
Responders []Entity `json:"responders,omitempty"`
VisibleTo []Entity `json:"visibleTo,omitempty"`
Actions []string `json:"actions,omitempty"`
Tags []string `json:"tags,omitempty"`
Details map[string]string `json:"details,omitempty"`
Entity string `json:"entity,omitempty"`
Source string `json:"source,omitempty"`
Priority string `json:"priority,omitempty"`
User string `json:"user,omitempty"`
Note string `json:"note,omitempty"`
}

View file

@ -0,0 +1,422 @@
package opsgenie
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
mockAPIKey = "eb243592-faa2-4ba2-a551q-1afdf565c889"
mockHost = "api.opsgenie.com"
)
func TestOpsGenie(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr OpsGenie Suite")
}
var _ = ginkgo.Describe("the OpsGenie service", func() {
var (
// a simulated http server to mock out OpsGenie itself
mockServer *httptest.Server
// the host of our mock server
mockHost string
// function to check if the http request received by the mock server is as expected
checkRequest func(body string, header http.Header)
// the shoutrrr OpsGenie service
service *Service
// just a mock logger
mockLogger *log.Logger
)
ginkgo.BeforeEach(func() {
// Initialize a mock http server
httpHandler := func(_ http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
defer r.Body.Close()
checkRequest(string(body), r.Header)
}
mockServer = httptest.NewTLSServer(http.HandlerFunc(httpHandler))
// Our mock server doesn't have a valid cert
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
// Determine the host of our mock http server
mockServerURL, err := url.Parse(mockServer.URL)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
mockHost = mockServerURL.Host
// Initialize a mock logger
var buf bytes.Buffer
mockLogger = log.New(&buf, "", 0)
})
ginkgo.AfterEach(func() {
mockServer.Close()
})
ginkgo.Context("without query parameters", func() {
ginkgo.BeforeEach(func() {
// Initialize service
serviceURL, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey))
gomega.Expect(err).ToNot(gomega.HaveOccurred())
service = &Service{}
err = service.Initialize(serviceURL, mockLogger)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
ginkgo.When("sending a simple alert", func() {
ginkgo.It("should send a request to our mock OpsGenie server", func() {
checkRequest = func(body string, header http.Header) {
gomega.Expect(header["Authorization"][0]).
To(gomega.Equal("GenieKey " + mockAPIKey))
gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json"))
gomega.Expect(body).To(gomega.Equal(`{"message":"hello world"}`))
}
err := service.Send("hello world", &types.Params{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
})
ginkgo.When("sending an alert with runtime parameters", func() {
ginkgo.It(
"should send a request to our mock OpsGenie server with all fields populated from runtime parameters",
func() {
checkRequest = func(body string, header http.Header) {
gomega.Expect(header["Authorization"][0]).
To(gomega.Equal("GenieKey " + mockAPIKey))
gomega.Expect(header["Content-Type"][0]).
To(gomega.Equal("application/json"))
gomega.Expect(body).To(gomega.Equal(`{"` +
`message":"An example alert message",` +
`"alias":"Life is too short for no alias",` +
`"description":"Every alert needs a description",` +
`"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` +
`"visibleTo":[{"type":"team","name":"rocket"}],` +
`"actions":["action1","action2"],` +
`"tags":["tag1","tag2"],` +
`"details":{"key1":"value1","key2":"value2"},` +
`"entity":"An example entity",` +
`"source":"The source",` +
`"priority":"P1",` +
`"user":"Dracula",` +
`"note":"Here is a note"` +
`}`))
}
err := service.Send("An example alert message", &types.Params{
"alias": "Life is too short for no alias",
"description": "Every alert needs a description",
"responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2",
"visibleTo": "team:rocket",
"actions": "action1,action2",
"tags": "tag1,tag2",
"details": "key1:value1,key2:value2",
"entity": "An example entity",
"source": "The source",
"priority": "P1",
"user": "Dracula",
"note": "Here is a note",
})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
},
)
})
})
ginkgo.Context("with query parameters", func() {
ginkgo.BeforeEach(func() {
// Initialize service
serviceURL, err := url.Parse(
fmt.Sprintf(
`opsgenie://%s/%s?alias=query-alias&description=query-description&responders=team:query_team&visibleTo=user:query_user&actions=queryAction1,queryAction2&tags=queryTag1,queryTag2&details=queryKey1:queryValue1,queryKey2:queryValue2&entity=query-entity&source=query-source&priority=P2&user=query-user&note=query-note`,
mockHost,
mockAPIKey,
),
)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
service = &Service{}
err = service.Initialize(serviceURL, mockLogger)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
ginkgo.When("sending a simple alert", func() {
ginkgo.It(
"should send a request to our mock OpsGenie server with all fields populated from query parameters",
func() {
checkRequest = func(body string, header http.Header) {
gomega.Expect(header["Authorization"][0]).
To(gomega.Equal("GenieKey " + mockAPIKey))
gomega.Expect(header["Content-Type"][0]).
To(gomega.Equal("application/json"))
gomega.Expect(body).To(gomega.Equal(`{` +
`"message":"An example alert message",` +
`"alias":"query-alias",` +
`"description":"query-description",` +
`"responders":[{"type":"team","name":"query_team"}],` +
`"visibleTo":[{"type":"user","username":"query_user"}],` +
`"actions":["queryAction1","queryAction2"],` +
`"tags":["queryTag1","queryTag2"],` +
`"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` +
`"entity":"query-entity",` +
`"source":"query-source",` +
`"priority":"P2",` +
`"user":"query-user",` +
`"note":"query-note"` +
`}`))
}
err := service.Send("An example alert message", &types.Params{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
},
)
})
ginkgo.When("sending two alerts", func() {
ginkgo.It("should not mix-up the runtime parameters and the query parameters", func() {
// Internally the opsgenie service copies runtime parameters into the config struct
// before generating the alert payload. This test ensures that none of the parameters
// from alert 1 remain in the config struct when sending alert 2
// In short: This tests if we clone the config struct
checkRequest = func(body string, header http.Header) {
gomega.Expect(header["Authorization"][0]).
To(gomega.Equal("GenieKey " + mockAPIKey))
gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json"))
gomega.Expect(body).To(gomega.Equal(`{"` +
`message":"1",` +
`"alias":"1",` +
`"description":"1",` +
`"responders":[{"type":"team","name":"1"}],` +
`"visibleTo":[{"type":"team","name":"1"}],` +
`"actions":["action1","action2"],` +
`"tags":["tag1","tag2"],` +
`"details":{"key1":"value1","key2":"value2"},` +
`"entity":"1",` +
`"source":"1",` +
`"priority":"P1",` +
`"user":"1",` +
`"note":"1"` +
`}`))
}
err := service.Send("1", &types.Params{
"alias": "1",
"description": "1",
"responders": "team:1",
"visibleTo": "team:1",
"actions": "action1,action2",
"tags": "tag1,tag2",
"details": "key1:value1,key2:value2",
"entity": "1",
"source": "1",
"priority": "P1",
"user": "1",
"note": "1",
})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
checkRequest = func(body string, header http.Header) {
gomega.Expect(header["Authorization"][0]).
To(gomega.Equal("GenieKey " + mockAPIKey))
gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json"))
gomega.Expect(body).To(gomega.Equal(`{` +
`"message":"2",` +
`"alias":"query-alias",` +
`"description":"query-description",` +
`"responders":[{"type":"team","name":"query_team"}],` +
`"visibleTo":[{"type":"user","username":"query_user"}],` +
`"actions":["queryAction1","queryAction2"],` +
`"tags":["queryTag1","queryTag2"],` +
`"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` +
`"entity":"query-entity",` +
`"source":"query-source",` +
`"priority":"P2",` +
`"user":"query-user",` +
`"note":"query-note"` +
`}`))
}
err = service.Send("2", nil)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("opsgenie"))
})
})
var _ = ginkgo.Describe("the OpsGenie Config struct", func() {
ginkgo.When("generating a config from a simple URL", func() {
ginkgo.It("should populate the config with host and apikey", func() {
url, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey))
gomega.Expect(err).ToNot(gomega.HaveOccurred())
config := Config{}
err = config.SetURL(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(config.APIKey).To(gomega.Equal(mockAPIKey))
gomega.Expect(config.Host).To(gomega.Equal(mockHost))
gomega.Expect(config.Port).To(gomega.Equal(uint16(443)))
})
})
ginkgo.When("generating a config from a url with port", func() {
ginkgo.It("should populate the port field", func() {
url, err := url.Parse(
fmt.Sprintf("opsgenie://%s/%s", net.JoinHostPort(mockHost, "12345"), mockAPIKey),
)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
config := Config{}
err = config.SetURL(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(config.Port).To(gomega.Equal(uint16(12345)))
})
})
ginkgo.When("generating a config from a url with query parameters", func() {
ginkgo.It("should populate the config fields with the query parameter values", func() {
queryParams := `alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=tag1,tag2&details=key:value,key2:value2&entity=An+example+entity&source=The+source&priority=P1&user=Dracula&note=Here+is+a+note&responders=user:Test,team:NOC&visibleTo=user:A+User`
url, err := url.Parse(
fmt.Sprintf(
"opsgenie://%s/%s?%s",
net.JoinHostPort(mockHost, "12345"),
mockAPIKey,
queryParams,
),
)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
config := Config{}
err = config.SetURL(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias"))
gomega.Expect(config.Description).To(gomega.Equal("Every alert needs a description"))
gomega.Expect(config.Responders).To(gomega.Equal([]Entity{
{Type: "user", Username: "Test"},
{Type: "team", Name: "NOC"},
}))
gomega.Expect(config.VisibleTo).To(gomega.Equal([]Entity{
{Type: "user", Username: "A User"},
}))
gomega.Expect(config.Actions).To(gomega.Equal([]string{"An action"}))
gomega.Expect(config.Tags).To(gomega.Equal([]string{"tag1", "tag2"}))
gomega.Expect(config.Details).
To(gomega.Equal(map[string]string{"key": "value", "key2": "value2"}))
gomega.Expect(config.Entity).To(gomega.Equal("An example entity"))
gomega.Expect(config.Source).To(gomega.Equal("The source"))
gomega.Expect(config.Priority).To(gomega.Equal("P1"))
gomega.Expect(config.User).To(gomega.Equal("Dracula"))
gomega.Expect(config.Note).To(gomega.Equal("Here is a note"))
})
})
ginkgo.When("generating a config from a url with differently escaped spaces", func() {
ginkgo.It("should parse the escaped spaces correctly", func() {
// Use: '%20', '+' and a normal space
queryParams := `alias=Life is+too%20short+for+no+alias`
url, err := url.Parse(
fmt.Sprintf(
"opsgenie://%s/%s?%s",
net.JoinHostPort(mockHost, "12345"),
mockAPIKey,
queryParams,
),
)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
config := Config{}
err = config.SetURL(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias"))
})
})
ginkgo.When("generating a url from a simple config", func() {
ginkgo.It("should generate a url", func() {
config := Config{
Host: "api.opsgenie.com",
APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889",
}
url := config.GetURL()
gomega.Expect(url.String()).
To(gomega.Equal("opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889"))
})
})
ginkgo.When("generating a url from a config with a port", func() {
ginkgo.It("should generate a url with port", func() {
config := Config{
Host: "api.opsgenie.com",
APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889",
Port: 12345,
}
url := config.GetURL()
gomega.Expect(url.String()).
To(gomega.Equal("opsgenie://api.opsgenie.com:12345/eb243592-faa2-4ba2-a551q-1afdf565c889"))
})
})
ginkgo.When("generating a url from a config with all optional config fields", func() {
ginkgo.It("should generate a url with query parameters", func() {
config := Config{
Host: "api.opsgenie.com",
APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889",
Alias: "Life is too short for no alias",
Description: "Every alert needs a description",
Responders: []Entity{
{Type: "user", Username: "Test"},
{Type: "team", Name: "NOC"},
{Type: "team", ID: "4513b7ea-3b91-438f-b7e4-e3e54af9147c"},
},
VisibleTo: []Entity{
{Type: "user", Username: "A User"},
},
Actions: []string{"action1", "action2"},
Tags: []string{"tag1", "tag2"},
Details: map[string]string{"key": "value"},
Entity: "An example entity",
Source: "The source",
Priority: "P1",
User: "Dracula",
Note: "Here is a note",
}
url := config.GetURL()
gomega.Expect(url.String()).
To(gomega.Equal(`opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?actions=action1%2Caction2&alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&details=key%3Avalue&entity=An+example+entity&note=Here+is+a+note&priority=P1&responders=user%3ATest%2Cteam%3ANOC%2Cteam%3A4513b7ea-3b91-438f-b7e4-e3e54af9147c&source=The+source&tags=tag1%2Ctag2&user=Dracula&visibleto=user%3AA+User`))
})
})
})

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

View file

@ -0,0 +1,114 @@
package pushover
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// hookURL is the Pushover API endpoint for sending messages.
const (
hookURL = "https://api.pushover.net/1/messages.json"
contentType = "application/x-www-form-urlencoded"
defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests.
)
// ErrSendFailed indicates a failure in sending the notification to a Pushover device.
var ErrSendFailed = errors.New("failed to send notification to pushover device")
// Service provides the Pushover notification service.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
Client *http.Client
}
// Send delivers a notification message to Pushover.
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)
}
device := strings.Join(config.Devices, ",")
if err := service.sendToDevice(device, message, config); err != nil {
return fmt.Errorf("failed to send notifications to pushover devices: %w", err)
}
return nil
}
// sendToDevice sends a notification to a specific Pushover device.
func (service *Service) sendToDevice(device string, message string, config *Config) error {
data := url.Values{}
data.Set("device", device)
data.Set("user", config.User)
data.Set("token", config.Token)
data.Set("message", message)
if len(config.Title) > 0 {
data.Set("title", config.Title)
}
if config.Priority >= -2 && config.Priority <= 1 {
data.Set("priority", strconv.FormatInt(int64(config.Priority), 10))
}
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
hookURL,
strings.NewReader(data.Encode()),
)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", contentType)
res, err := service.Client.Do(req)
if err != nil {
return fmt.Errorf("sending request to Pushover API: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %q, response status %q", ErrSendFailed, device, res.Status)
}
return nil
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
service.Client = &http.Client{
Timeout: defaultHTTPTimeout,
}
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}

View file

@ -0,0 +1,83 @@
package pushover
import (
"errors"
"fmt"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the identifying part of this service's configuration URL.
const Scheme = "pushover"
// Static errors for configuration validation.
var (
ErrUserMissing = errors.New("user missing from config URL")
ErrTokenMissing = errors.New("token missing from config URL")
)
// Config for the Pushover notification service.
type Config struct {
Token string `desc:"API Token/Key" url:"pass"`
User string `desc:"User Key" url:"host"`
Devices []string ` key:"devices" optional:""`
Priority int8 ` key:"priority" default:"0"`
Title string ` key:"title" optional:""`
}
// Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}
// GetURL returns a URL representation of its 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)
}
// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
password, _ := url.User.Password()
config.User = url.Host
config.Token = password
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)
}
}
if url.String() != "pushover://dummy@dummy.com" {
if len(config.User) < 1 {
return ErrUserMissing
}
if len(config.Token) < 1 {
return ErrTokenMissing
}
}
return nil
}
// 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{
User: url.UserPassword("Token", config.Token),
Host: config.User,
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}

View file

@ -0,0 +1,11 @@
package pushover
// ErrorMessage for error events within the pushover service.
type ErrorMessage string
const (
// UserMissing should be used when a config URL is missing a user.
UserMissing ErrorMessage = "user missing from config URL"
// TokenMissing should be used when a config URL is missing a token.
TokenMissing ErrorMessage = "token missing from config URL"
)

View file

@ -0,0 +1,197 @@
package pushover_test
import (
"errors"
"log"
"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/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushover"
)
const hookURL = "https://api.pushover.net/1/messages.json"
func TestPushover(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Pushover Suite")
}
var (
service *pushover.Service
config *pushover.Config
keyResolver format.PropKeyResolver
envPushoverURL *url.URL
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
service = &pushover.Service{}
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
envPushoverURL, _ = url.Parse(os.Getenv("SHOUTRRR_PUSHOVER_URL"))
})
)
var _ = ginkgo.Describe("the pushover service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should work", func() {
if envPushoverURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envPushoverURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("pushover"))
})
})
})
var _ = ginkgo.Describe("the pushover config", func() {
ginkgo.BeforeEach(func() {
config = &pushover.Config{}
keyResolver = format.NewPropKeyResolver(config)
})
ginkgo.When("updating it using an url", func() {
ginkgo.It("should update the username using the host part of the url", func() {
url := createURL("simme", "dummy")
err := config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.User).To(gomega.Equal("simme"))
})
ginkgo.It("should update the token using the password part of the url", func() {
url := createURL("dummy", "TestToken")
err := config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Token).To(gomega.Equal("TestToken"))
})
ginkgo.It("should error if supplied with an empty username", func() {
url := createURL("", "token")
expectErrorMessageGivenURL(pushover.UserMissing, url)
})
ginkgo.It("should error if supplied with an empty token", func() {
url := createURL("user", "")
expectErrorMessageGivenURL(pushover.TokenMissing, url)
})
})
ginkgo.When("getting the current config", func() {
ginkgo.It("should return the config that is currently set as an url", func() {
config.User = "simme"
config.Token = "test-token"
url := config.GetURL()
password, _ := url.User.Password()
gomega.Expect(url.Host).To(gomega.Equal(config.User))
gomega.Expect(password).To(gomega.Equal(config.Token))
gomega.Expect(url.Scheme).To(gomega.Equal("pushover"))
})
})
ginkgo.When("setting a config key", func() {
ginkgo.It("should split it by commas if the key is devices", func() {
err := keyResolver.Set("devices", "a,b,c,d")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Devices).To(gomega.Equal([]string{"a", "b", "c", "d"}))
})
ginkgo.It("should update priority when a valid number is supplied", func() {
err := keyResolver.Set("priority", "1")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Priority).To(gomega.Equal(int8(1)))
})
ginkgo.It("should update priority when a negative number is supplied", func() {
gomega.Expect(keyResolver.Set("priority", "-1")).To(gomega.Succeed())
gomega.Expect(config.Priority).To(gomega.BeEquivalentTo(-1))
gomega.Expect(keyResolver.Set("priority", "-2")).To(gomega.Succeed())
gomega.Expect(config.Priority).To(gomega.BeEquivalentTo(-2))
})
ginkgo.It("should update the title when it is supplied", func() {
err := keyResolver.Set("title", "new title")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Title).To(gomega.Equal("new title"))
})
ginkgo.It("should return an error if priority is not a number", func() {
err := keyResolver.Set("priority", "super-duper")
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should return an error if the key is not recognized", func() {
err := keyResolver.Set("devicey", "a,b,c,d")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("getting a config key", func() {
ginkgo.It("should join it with commas if the key is devices", func() {
config.Devices = []string{"a", "b", "c"}
value, err := keyResolver.Get("devices")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(value).To(gomega.Equal("a,b,c"))
})
ginkgo.It("should return an error if the key is not recognized", func() {
_, err := keyResolver.Get("devicey")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("listing the query fields", func() {
ginkgo.It("should return the keys \"devices\",\"priority\",\"title\"", func() {
fields := keyResolver.QueryFields()
gomega.Expect(fields).To(gomega.Equal([]string{"devices", "priority", "title"}))
})
})
ginkgo.Describe("sending the payload", func() {
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL, err := url.Parse("pushover://:apptoken@usertoken")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder("POST", hookURL, httpmock.NewStringResponder(200, ""))
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() {
serviceURL, err := url.Parse("pushover://:apptoken@usertoken")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
hookURL,
httpmock.NewErrorResponder(errors.New("dummy error")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
func createURL(username string, token string) *url.URL {
return &url.URL{
User: url.UserPassword("Token", token),
Host: username,
}
}
func expectErrorMessageGivenURL(msg pushover.ErrorMessage, url *url.URL) {
err := config.SetURL(url)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.Equal(string(msg)))
}

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

View file

@ -0,0 +1,142 @@
package services_test
import (
"log"
"net/http"
"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/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestServices(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Service Compliance Suite")
}
var serviceURLs = map[string]string{
"discord": "discord://token@id",
"gotify": "gotify://example.com/Aaa.bbb.ccc.ddd",
"googlechat": "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
"hangouts": "hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz",
"ifttt": "ifttt://key?events=event",
"join": "join://:apikey@join/?devices=device",
"logger": "logger://",
"mattermost": "mattermost://user@example.com/token",
"opsgenie": "opsgenie://example.com/token?responders=user:dummy",
"pushbullet": "pushbullet://tokentokentokentokentokentokentoke",
"pushover": "pushover://:token@user/?devices=device",
"rocketchat": "rocketchat://example.com/token/channel",
"slack": "slack://AAAAAAAAA/BBBBBBBBB/123456789123456789123456",
"smtp": "smtp://host.tld:25/?fromAddress=from@host.tld&toAddresses=to@host.tld",
"teams": "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=test.webhook.office.com",
"telegram": "telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram?channels=channel",
"xmpp": "xmpp://",
"zulip": "zulip://mail:key@example.com/?stream=foo&topic=bar",
}
var serviceResponses = map[string]string{
"discord": "",
"gotify": `{"id": 0}`,
"googlechat": "",
"hangouts": "",
"ifttt": "",
"join": "",
"logger": "",
"mattermost": "",
"opsgenie": "",
"pushbullet": `{"type": "note", "body": "test", "title": "test title", "active": true, "created": 0}`,
"pushover": "",
"rocketchat": "",
"slack": "",
"smtp": "",
"teams": "",
"telegram": "",
"xmpp": "",
"zulip": "",
}
var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
var _ = ginkgo.Describe("services", func() {
ginkgo.BeforeEach(func() {
})
ginkgo.AfterEach(func() {
})
ginkgo.When("passed the a title param", func() {
var serviceRouter *router.ServiceRouter
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
for key, configURL := range serviceURLs {
serviceRouter, _ = router.New(logger)
ginkgo.It("should not throw an error for "+key, func() {
if key == "smtp" {
ginkgo.Skip("smtp does not use HTTP and needs a specific test")
}
if key == "xmpp" {
ginkgo.Skip("not supported")
}
service, err := serviceRouter.Locate(configURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.Activate()
if mockService, ok := service.(testutils.MockClientService); ok {
httpmock.ActivateNonDefault(mockService.GetHTTPClient())
}
respStatus := http.StatusOK
if key == "discord" || key == "ifttt" {
respStatus = http.StatusNoContent
}
if key == "mattermost" {
httpmock.RegisterResponder(
"POST",
"https://example.com/hooks/token",
httpmock.NewStringResponder(http.StatusOK, ""),
)
} else {
httpmock.RegisterNoResponder(httpmock.NewStringResponder(respStatus, serviceResponses[key]))
}
err = service.Send("test", (*types.Params)(&map[string]string{
"title": "test title",
}))
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
if key == "mattermost" {
ginkgo.It("should not throw an error for "+key+" with DisableTLS", func() {
modifiedURL := configURL + "?disabletls=yes"
service, err := serviceRouter.Locate(modifiedURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.Activate()
if mockService, ok := service.(testutils.MockClientService); ok {
httpmock.ActivateNonDefault(mockService.GetHTTPClient())
}
httpmock.RegisterResponder(
"POST",
"http://example.com/hooks/token",
httpmock.NewStringResponder(http.StatusOK, ""),
)
err = service.Send("test", (*types.Params)(&map[string]string{
"title": "test title",
}))
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
}
}
})
})

142
pkg/services/slack/slack.go Normal file
View file

@ -0,0 +1,142 @@
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"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"
)
// apiPostMessage is the Slack API endpoint for sending messages.
const (
apiPostMessage = "https://slack.com/api/chat.postMessage"
defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests.
)
// Service sends notifications to a pre-configured Slack channel or user.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
client *http.Client
}
// Send delivers a notification message to Slack.
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)
}
payload := CreateJSONPayload(config, message)
var err error
if config.Token.IsAPIToken() {
err = service.sendAPI(config, payload)
} else {
err = service.sendWebhook(config, payload)
}
if err != nil {
return fmt.Errorf("failed to send slack notification: %w", err)
}
return nil
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
service.client = &http.Client{
Timeout: defaultHTTPTimeout,
}
return service.Config.setURL(&service.pkr, configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// sendAPI sends a notification using the Slack API.
func (service *Service) sendAPI(config *Config, payload any) error {
response := APIResponse{}
jsonClient := jsonclient.NewClient()
jsonClient.Headers().Set("Authorization", config.Token.Authorization())
if err := jsonClient.Post(apiPostMessage, payload, &response); err != nil {
return fmt.Errorf("posting to Slack API: %w", err)
}
if !response.Ok {
if response.Error != "" {
return fmt.Errorf("%w: %v", ErrAPIResponseFailure, response.Error)
}
return ErrUnknownAPIError
}
if response.Warning != "" {
service.Logf("Slack API warning: %q", response.Warning)
}
return nil
}
// sendWebhook sends a notification using a Slack webhook.
func (service *Service) sendWebhook(config *Config, payload any) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
config.Token.WebhookURL(),
bytes.NewBuffer(payloadBytes),
)
if err != nil {
return fmt.Errorf("failed to create webhook request: %w", err)
}
req.Header.Set("Content-Type", jsonclient.ContentType)
res, err := service.client.Do(req)
if err != nil {
return fmt.Errorf("failed to invoke webhook: %w", err)
}
defer res.Body.Close()
resBytes, _ := io.ReadAll(res.Body)
response := string(resBytes)
switch response {
case "":
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %v", ErrWebhookStatusFailure, res.Status)
}
fallthrough
case "ok":
return nil
default:
return fmt.Errorf("%w: %v", ErrWebhookResponseFailure, response)
}
}

View file

@ -0,0 +1,91 @@
package slack
import (
"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"
)
const (
// Scheme is the identifying part of this service's configuration URL.
Scheme = "slack"
)
// Config for the slack service.
type Config struct {
standard.EnumlessConfig
BotName string `desc:"Bot name" key:"botname,username" optional:"uses bot default"`
Icon string `desc:"Use emoji or URL as icon (based on presence of http(s):// prefix)" key:"icon,icon_emoji,icon_url" optional:"" default:""`
Token Token `desc:"API Bot token" url:"user,pass"`
Color string `desc:"Message left-hand border color" key:"color" optional:"default border color"`
Title string `desc:"Prepended text above the message" key:"title" optional:"omitted"`
Channel string `desc:"Channel to send messages to in Cxxxxxxxxxx format" url:"host"`
ThreadTS string `desc:"ts value of the parent message (to send message as reply in thread)" key:"thread_ts" optional:""`
}
// GetURL returns a URL representation of it's current field values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates a ServiceConfig from a URL representation of it's field values.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
User: config.Token.UserInfo(),
Host: config.Channel,
Scheme: Scheme,
ForceQuery: false,
RawQuery: format.BuildQuery(resolver),
}
}
func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error {
var token string
var err error
if len(serviceURL.Path) > 1 {
// Reading legacy config URL format
token = serviceURL.Hostname() + serviceURL.Path
config.Channel = "webhook"
config.BotName = serviceURL.User.Username()
} else {
token = serviceURL.User.String()
config.Channel = serviceURL.Hostname()
}
if serviceURL.String() != "slack://dummy@dummy.com" {
if err = config.Token.SetFromProp(token); err != nil {
return err
}
} else {
config.Token.raw = token // Set raw token without validation
}
for key, vals := range serviceURL.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
}
// CreateConfigFromURL to use within the slack service.
func CreateConfigFromURL(serviceURL *url.URL) (*Config, error) {
config := Config{}
err := config.SetURL(serviceURL)
return &config, err
}

View file

@ -0,0 +1,21 @@
package slack
import "errors"
// ErrInvalidToken is returned when the specified token does not match any known formats.
var ErrInvalidToken = errors.New("invalid slack token format")
// ErrMismatchedTokenSeparators is returned if the token uses different separators between parts (of the recognized `/-,`).
var ErrMismatchedTokenSeparators = errors.New("invalid webhook token format")
// ErrAPIResponseFailure indicates a failure in the Slack API response.
var ErrAPIResponseFailure = errors.New("api response failure")
// ErrUnknownAPIError indicates an unknown error from the Slack API.
var ErrUnknownAPIError = errors.New("unknown error from Slack API")
// ErrWebhookStatusFailure indicates a failure due to an unexpected webhook status code.
var ErrWebhookStatusFailure = errors.New("webhook status failure")
// ErrWebhookResponseFailure indicates a failure in the webhook response content.
var ErrWebhookResponseFailure = errors.New("webhook response failure")

View file

@ -0,0 +1,125 @@
package slack
import (
"regexp"
"strings"
)
// Constants for Slack API limits.
const (
MaxAttachments = 100 // Maximum number of attachments allowed by Slack API
)
var iconURLPattern = regexp.MustCompile(`https?://`)
// MessagePayload used within the Slack service.
type MessagePayload struct {
Text string `json:"text"`
BotName string `json:"username,omitempty"`
Blocks []block `json:"blocks,omitempty"`
Attachments []attachment `json:"attachments,omitempty"`
ThreadTS string `json:"thread_ts,omitempty"`
Channel string `json:"channel,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
IconURL string `json:"icon_url,omitempty"`
}
type block struct {
Type string `json:"type"`
Text blockText `json:"text"`
}
type blockText struct {
Type string `json:"type"`
Text string `json:"text"`
}
type attachment struct {
Title string `json:"title,omitempty"`
Fallback string `json:"fallback,omitempty"`
Text string `json:"text"`
Color string `json:"color,omitempty"`
Fields []legacyField `json:"fields,omitempty"`
Footer string `json:"footer,omitempty"`
Time int `json:"ts,omitempty"`
}
type legacyField struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short,omitempty"`
}
// APIResponse is the default generic response message sent from the API.
type APIResponse struct {
Ok bool `json:"ok"`
Error string `json:"error"`
Warning string `json:"warning"`
MetaData struct {
Warnings []string `json:"warnings"`
} `json:"response_metadata"`
}
// CreateJSONPayload compatible with the slack post message API.
func CreateJSONPayload(config *Config, message string) any {
lines := strings.Split(message, "\n")
// Pre-allocate atts with a capacity of min(len(lines), MaxAttachments)
atts := make([]attachment, 0, minInt(len(lines), MaxAttachments))
for i, line := range lines {
// When MaxAttachments have been reached, append the remaining lines to the last attachment
if i >= MaxAttachments {
atts[MaxAttachments-1].Text += "\n" + line
continue
}
atts = append(atts, attachment{
Text: line,
Color: config.Color,
})
}
// Remove last attachment if empty
if len(atts) > 0 && atts[len(atts)-1].Text == "" {
atts = atts[:len(atts)-1]
}
payload := MessagePayload{
ThreadTS: config.ThreadTS,
Text: config.Title,
BotName: config.BotName,
Attachments: atts,
}
payload.SetIcon(config.Icon)
if config.Channel != "webhook" {
payload.Channel = config.Channel
}
return payload
}
// SetIcon sets the appropriate icon field in the payload based on whether the input is a URL or not.
func (p *MessagePayload) SetIcon(icon string) {
p.IconURL = ""
p.IconEmoji = ""
if icon != "" {
if iconURLPattern.MatchString(icon) {
p.IconURL = icon
} else {
p.IconEmoji = icon
}
}
}
// minInt returns the smaller of two integers.
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,332 @@
package slack_test
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/services/slack"
)
const (
TestWebhookURL = "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456"
)
func TestSlack(t *testing.T) {
format.CharactersAroundMismatchToInclude = 20
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Slack Suite")
}
var (
service *slack.Service
envSlackURL *url.URL
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
service = &slack.Service{}
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
envSlackURL, _ = url.Parse(os.Getenv("SHOUTRRR_SLACK_URL"))
})
)
var _ = ginkgo.Describe("the slack service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
if envSlackURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envSlackURL.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("slack"))
})
})
// xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N
ginkgo.When("given a token with a malformed part", func() {
ginkgo.It("should return an error if part A is not 9 letters", func() {
expectErrorMessageGivenURL(
slack.ErrInvalidToken,
"slack://lol@12345678/123456789/123456789123456789123456",
)
})
ginkgo.It("should return an error if part B is not 9 letters", func() {
expectErrorMessageGivenURL(
slack.ErrInvalidToken,
"slack://lol@123456789/12345678/123456789123456789123456",
)
})
ginkgo.It("should return an error if part C is not 24 letters", func() {
expectErrorMessageGivenURL(
slack.ErrInvalidToken,
"slack://123456789/123456789/12345678912345678912345",
)
})
})
ginkgo.When("given a token missing a part", func() {
ginkgo.It("should return an error if the missing part is A", func() {
expectErrorMessageGivenURL(
slack.ErrInvalidToken,
"slack://lol@/123456789/123456789123456789123456",
)
})
ginkgo.It("should return an error if the missing part is B", func() {
expectErrorMessageGivenURL(slack.ErrInvalidToken, "slack://lol@123456789//123456789")
})
ginkgo.It("should return an error if the missing part is C", func() {
expectErrorMessageGivenURL(slack.ErrInvalidToken, "slack://lol@123456789/123456789/")
})
})
ginkgo.Describe("the slack config", func() {
ginkgo.When("parsing the configuration URL", func() {
ginkgo.When("given a config using the legacy format", func() {
ginkgo.It("should be converted to the new format after de-/serialization", func() {
oldURL := "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456?color=3f00fe&title=Test+title"
newURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title"
config := &slack.Config{}
err := config.SetURL(testutils.URLMust(oldURL))
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
gomega.Expect(config.GetURL().String()).To(gomega.Equal(newURL))
})
})
})
ginkgo.When("the URL contains an invalid property", func() {
testURL := testutils.URLMust(
"slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?bass=dirty",
)
err := (&slack.Config{}).SetURL(testURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should be identical after de-/serialization", func() {
testURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title"
config := &slack.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.When("generating a config object", func() {
ginkgo.It(
"should use the default botname if the argument list contains three strings",
func() {
slackURL, _ := url.Parse("slack://AAAAAAAAA/BBBBBBBBB/123456789123456789123456")
config, configError := slack.CreateConfigFromURL(slackURL)
gomega.Expect(configError).NotTo(gomega.HaveOccurred())
gomega.Expect(config.BotName).To(gomega.BeEmpty())
},
)
ginkgo.It("should set the botname if the argument list is three", func() {
slackURL, _ := url.Parse(
"slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456",
)
config, configError := slack.CreateConfigFromURL(slackURL)
gomega.Expect(configError).NotTo(gomega.HaveOccurred())
gomega.Expect(config.BotName).To(gomega.Equal("testbot"))
})
ginkgo.It("should return an error if the argument list is shorter than three", func() {
slackURL, _ := url.Parse("slack://AAAAAAAA")
_, configError := slack.CreateConfigFromURL(slackURL)
gomega.Expect(configError).To(gomega.HaveOccurred())
})
})
ginkgo.When("getting credentials from token", func() {
ginkgo.It("should return a valid webhook URL for the given token", func() {
token := tokenMust("AAAAAAAAA/BBBBBBBBB/123456789123456789123456")
gomega.Expect(token.WebhookURL()).To(gomega.Equal(TestWebhookURL))
})
ginkgo.It(
"should return a valid authorization header value for the given token",
func() {
token := tokenMust("xoxb:AAAAAAAAA-BBBBBBBBB-123456789123456789123456")
expected := "Bearer xoxb-AAAAAAAAA-BBBBBBBBB-123456789123456789123456"
gomega.Expect(token.Authorization()).To(gomega.Equal(expected))
},
)
})
})
ginkgo.Describe("creating the payload", func() {
ginkgo.Describe("the icon fields", func() {
payload := slack.MessagePayload{}
ginkgo.It("should set IconURL when the configured icon looks like an URL", func() {
payload.SetIcon("https://example.com/logo.png")
gomega.Expect(payload.IconURL).To(gomega.Equal("https://example.com/logo.png"))
gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty())
})
ginkgo.It(
"should set IconEmoji when the configured icon does not look like an URL",
func() {
payload.SetIcon("tanabata_tree")
gomega.Expect(payload.IconEmoji).To(gomega.Equal("tanabata_tree"))
gomega.Expect(payload.IconURL).To(gomega.BeEmpty())
},
)
ginkgo.It("should clear both fields when icon is empty", func() {
payload.SetIcon("")
gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty())
gomega.Expect(payload.IconURL).To(gomega.BeEmpty())
})
})
ginkgo.When("when more than 99 lines are being sent", func() {
ginkgo.It("should append the exceeding lines to the last attachment", func() {
config := slack.Config{}
sb := strings.Builder{}
for i := 1; i <= 110; i++ {
sb.WriteString(fmt.Sprintf("Line %d\n", i))
}
payload := slack.CreateJSONPayload(&config, sb.String()).(slack.MessagePayload)
atts := payload.Attachments
fmt.Fprint(
ginkgo.GinkgoWriter,
"\nLines: ",
len(atts),
" Last: ",
atts[len(atts)-1],
"\n",
)
gomega.Expect(atts).To(gomega.HaveLen(100))
gomega.Expect(atts[len(atts)-1].Text).To(gomega.ContainSubstring("Line 110"))
})
})
ginkgo.When("when the last message line ends with a newline", func() {
ginkgo.It("should not send an empty attachment", func() {
payload := slack.CreateJSONPayload(&slack.Config{}, "One\nTwo\nThree\n").(slack.MessagePayload)
atts := payload.Attachments
gomega.Expect(atts[len(atts)-1].Text).NotTo(gomega.BeEmpty())
})
})
})
ginkgo.Describe("sending the payload", func() {
ginkgo.When("sending via webhook URL", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL, _ := url.Parse(
"slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456",
)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
TestWebhookURL,
httpmock.NewStringResponder(200, ""),
)
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() {
serviceURL, _ := url.Parse(
"slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456",
)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
TestWebhookURL,
httpmock.NewErrorResponder(errors.New("dummy error")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("sending via bot API", func() {
var err error
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL := testutils.URLMust(
"slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789",
)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
targetURL := "https://slack.com/api/chat.postMessage"
httpmock.RegisterResponder(
"POST",
targetURL,
testutils.JSONRespondMust(200, slack.APIResponse{
Ok: true,
}),
)
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() {
serviceURL := testutils.URLMust(
"slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789",
)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
targetURL := "https://slack.com/api/chat.postMessage"
httpmock.RegisterResponder(
"POST",
targetURL,
testutils.JSONRespondMust(200, slack.APIResponse{
Error: "someone turned off the internet",
}),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
})
func tokenMust(rawToken string) *slack.Token {
token, err := slack.ParseToken(rawToken)
gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred())
return token
}
func expectErrorMessageGivenURL(expected error, rawURL string) {
err := service.Initialize(testutils.URLMust(rawURL), testutils.TestLogger())
gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred())
gomega.ExpectWithOffset(1, err).To(gomega.Equal(expected))
}

View file

@ -0,0 +1,154 @@
package slack
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const webhookBase = "https://hooks.slack.com/services/"
// Token type identifiers.
const (
HookTokenIdentifier = "hook"
UserTokenIdentifier = "xoxp"
BotTokenIdentifier = "xoxb"
)
// Token length and offset constants.
const (
MinTokenLength = 3 // Minimum length for a valid token string
TypeIdentifierLength = 4 // Length of the type identifier (e.g., "xoxb", "hook")
TypeIdentifierOffset = 5 // Offset to skip type identifier and separator (e.g., "xoxb:")
Part1Length = 9 // Expected length of part 1 in token
Part2Length = 9 // Expected length of part 2 in token
Part3Length = 24 // Expected length of part 3 in token
)
// Token match group indices.
const (
tokenMatchFull = iota // Full match
tokenMatchType // Type identifier (e.g., "xoxb", "hook")
tokenMatchPart1 // First part of the token
tokenMatchSep1 // First separator
tokenMatchPart2 // Second part of the token
tokenMatchSep2 // Second separator
tokenMatchPart3 // Third part of the token
tokenMatchCount // Total number of match groups
)
var tokenPattern = regexp.MustCompile(
`(?:(?P<type>xox.|hook)[-:]|:?)(?P<p1>[A-Z0-9]{` + strconv.Itoa(
Part1Length,
) + `,})(?P<s1>[-/,])(?P<p2>[A-Z0-9]{` + strconv.Itoa(
Part2Length,
) + `,})(?P<s2>[-/,])(?P<p3>[A-Za-z0-9]{` + strconv.Itoa(
Part3Length,
) + `,})`,
)
var _ types.ConfigProp = &Token{}
// Token is a Slack API token or a Slack webhook token.
type Token struct {
raw string
}
// SetFromProp sets the token from a property value, implementing the types.ConfigProp interface.
func (token *Token) SetFromProp(propValue string) error {
if len(propValue) < MinTokenLength {
return ErrInvalidToken
}
match := tokenPattern.FindStringSubmatch(propValue)
if match == nil || len(match) != tokenMatchCount {
return ErrInvalidToken
}
typeIdentifier := match[tokenMatchType]
if typeIdentifier == "" {
typeIdentifier = HookTokenIdentifier
}
token.raw = fmt.Sprintf("%s:%s-%s-%s",
typeIdentifier, match[tokenMatchPart1], match[tokenMatchPart2], match[tokenMatchPart3])
if match[tokenMatchSep1] != match[tokenMatchSep2] {
return ErrMismatchedTokenSeparators
}
return nil
}
// GetPropValue returns the token as a property value, implementing the types.ConfigProp interface.
func (token *Token) GetPropValue() (string, error) {
if token == nil {
return "", nil
}
return token.raw, nil
}
// TypeIdentifier returns the type identifier of the token.
func (token *Token) TypeIdentifier() string {
return token.raw[:TypeIdentifierLength]
}
// ParseToken parses and normalizes a token string.
func ParseToken(str string) (*Token, error) {
token := &Token{}
if err := token.SetFromProp(str); err != nil {
return nil, err
}
return token, nil
}
// String returns the token in normalized format with dashes (-) as separator.
func (token *Token) String() string {
return token.raw
}
// UserInfo returns a url.Userinfo struct populated from the token.
func (token *Token) UserInfo() *url.Userinfo {
return url.UserPassword(token.raw[:TypeIdentifierLength], token.raw[TypeIdentifierOffset:])
}
// IsAPIToken returns whether the identifier is set to anything else but the webhook identifier (`hook`).
func (token *Token) IsAPIToken() bool {
return token.TypeIdentifier() != HookTokenIdentifier
}
// WebhookURL returns the corresponding Webhook URL for the token.
func (token *Token) WebhookURL() string {
stringBuilder := strings.Builder{}
stringBuilder.WriteString(webhookBase)
stringBuilder.Grow(len(token.raw) - TypeIdentifierOffset)
for i := TypeIdentifierOffset; i < len(token.raw); i++ {
c := token.raw[i]
if c == '-' {
c = '/'
}
stringBuilder.WriteByte(c)
}
return stringBuilder.String()
}
// Authorization returns the corresponding `Authorization` HTTP header value for the token.
func (token *Token) Authorization() string {
stringBuilder := strings.Builder{}
stringBuilder.WriteString("Bearer ")
stringBuilder.Grow(len(token.raw))
stringBuilder.WriteString(token.raw[:TypeIdentifierLength])
stringBuilder.WriteRune('-')
stringBuilder.WriteString(token.raw[TypeIdentifierOffset:])
return stringBuilder.String()
}

366
pkg/services/smtp/smtp.go Normal file
View file

@ -0,0 +1,366 @@
package smtp
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/smtp"
"net/url"
"os"
"strconv"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
contentHTML = "text/html; charset=\"UTF-8\""
contentPlain = "text/plain; charset=\"UTF-8\""
contentMultipart = "multipart/alternative; boundary=%s"
DefaultSMTPPort = 25 // DefaultSMTPPort is the standard port for SMTP communication.
boundaryByteLen = 8 // boundaryByteLen is the number of bytes for the multipart boundary.
)
// ErrNoAuth is a sentinel error indicating no authentication is required.
var ErrNoAuth = errors.New("no authentication required")
// Service sends notifications to given email addresses via SMTP.
type Service struct {
standard.Standard
standard.Templater
Config *Config
multipartBoundary string
propKeyResolver 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{
Port: DefaultSMTPPort,
ToAddresses: nil,
Subject: "",
Auth: AuthTypes.Unknown,
UseStartTLS: true,
UseHTML: false,
Encryption: EncMethods.Auto,
ClientHost: "localhost",
}
pkr := format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&pkr, configURL); err != nil {
return err
}
if service.Config.Auth == AuthTypes.Unknown {
if service.Config.Username != "" {
service.Config.Auth = AuthTypes.Plain
} else {
service.Config.Auth = AuthTypes.None
}
}
service.propKeyResolver = pkr
return nil
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// Send sends a notification message to email recipients.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config.Clone()
if err := service.propKeyResolver.UpdateConfigFromParams(&config, params); err != nil {
return fail(FailApplySendParams, err)
}
client, err := getClientConnection(service.Config)
if err != nil {
return fail(FailGetSMTPClient, err)
}
return service.doSend(client, message, &config)
}
// getClientConnection establishes a connection to the SMTP server using the provided configuration.
func getClientConnection(config *Config) (*smtp.Client, error) {
var conn net.Conn
var err error
addr := net.JoinHostPort(config.Host, strconv.FormatUint(uint64(config.Port), 10))
if useImplicitTLS(config.Encryption, config.Port) {
conn, err = tls.Dial("tcp", addr, &tls.Config{
ServerName: config.Host,
MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher
})
} else {
conn, err = net.Dial("tcp", addr)
}
if err != nil {
return nil, fail(FailConnectToServer, err)
}
client, err := smtp.NewClient(conn, config.Host)
if err != nil {
return nil, fail(FailCreateSMTPClient, err)
}
return client, nil
}
// doSend sends an email message using the provided SMTP client and configuration.
func (service *Service) doSend(client *smtp.Client, message string, config *Config) failure {
config.FixEmailTags()
clientHost := service.resolveClientHost(config)
if err := client.Hello(clientHost); err != nil {
return fail(FailHandshake, err)
}
if config.UseHTML {
b := make([]byte, boundaryByteLen)
if _, err := rand.Read(b); err != nil {
return fail(FailUnknown, err) // Fallback error for rare case
}
service.multipartBoundary = hex.EncodeToString(b)
}
if config.UseStartTLS && !useImplicitTLS(config.Encryption, config.Port) {
if supported, _ := client.Extension("StartTLS"); !supported {
service.Logf(
"Warning: StartTLS enabled, but server does not support it. Connection is unencrypted",
)
} else {
if err := client.StartTLS(&tls.Config{
ServerName: config.Host,
MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher
}); err != nil {
return fail(FailEnableStartTLS, err)
}
}
}
if auth, err := service.getAuth(config); err != nil {
return err
} else if auth != nil {
if err := client.Auth(auth); err != nil {
return fail(FailAuthenticating, err)
}
}
for _, toAddress := range config.ToAddresses {
err := service.sendToRecipient(client, toAddress, config, message)
if err != nil {
return fail(FailSendRecipient, err)
}
service.Logf("Mail successfully sent to \"%s\"!\n", toAddress)
}
// Send the QUIT command and close the connection.
err := client.Quit()
if err != nil {
return fail(FailClosingSession, err)
}
return nil
}
// resolveClientHost determines the client hostname to use in the SMTP handshake.
func (service *Service) resolveClientHost(config *Config) string {
if config.ClientHost != "auto" {
return config.ClientHost
}
hostname, err := os.Hostname()
if err != nil {
service.Logf("Failed to get hostname, falling back to localhost: %v", err)
return "localhost"
}
return hostname
}
// getAuth returns the appropriate SMTP authentication mechanism based on the configuration.
//
//nolint:exhaustive,nilnil
func (service *Service) getAuth(config *Config) (smtp.Auth, failure) {
switch config.Auth {
case AuthTypes.None:
return nil, nil // No auth required, proceed without error
case AuthTypes.Plain:
return smtp.PlainAuth("", config.Username, config.Password, config.Host), nil
case AuthTypes.CRAMMD5:
return smtp.CRAMMD5Auth(config.Username, config.Password), nil
case AuthTypes.OAuth2:
return OAuth2Auth(config.Username, config.Password), nil
case AuthTypes.Unknown:
return nil, fail(FailAuthType, nil, config.Auth.String())
default:
return nil, fail(FailAuthType, nil, config.Auth.String())
}
}
// sendToRecipient sends an email to a single recipient using the provided SMTP client.
func (service *Service) sendToRecipient(
client *smtp.Client,
toAddress string,
config *Config,
message string,
) failure {
// Set the sender and recipient first
if err := client.Mail(config.FromAddress); err != nil {
return fail(FailSetSender, err)
}
if err := client.Rcpt(toAddress); err != nil {
return fail(FailSetRecipient, err)
}
// Send the email body.
writeCloser, err := client.Data()
if err != nil {
return fail(FailOpenDataStream, err)
}
if err := writeHeaders(writeCloser, service.getHeaders(toAddress, config.Subject)); err != nil {
return err
}
var ferr failure
if config.UseHTML {
ferr = service.writeMultipartMessage(writeCloser, message)
} else {
ferr = service.writeMessagePart(writeCloser, message, "plain")
}
if ferr != nil {
return ferr
}
if err = writeCloser.Close(); err != nil {
return fail(FailCloseDataStream, err)
}
return nil
}
// getHeaders constructs email headers for the SMTP message.
func (service *Service) getHeaders(toAddress string, subject string) map[string]string {
conf := service.Config
var contentType string
if conf.UseHTML {
contentType = fmt.Sprintf(contentMultipart, service.multipartBoundary)
} else {
contentType = contentPlain
}
return map[string]string{
"Subject": subject,
"Date": time.Now().Format(time.RFC1123Z),
"To": toAddress,
"From": fmt.Sprintf("%s <%s>", conf.FromName, conf.FromAddress),
"MIME-version": "1.0",
"Content-Type": contentType,
}
}
// writeMultipartMessage writes a multipart email message to the provided writer.
func (service *Service) writeMultipartMessage(writeCloser io.WriteCloser, message string) failure {
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentPlain); err != nil {
return fail(FailPlainHeader, err)
}
if err := service.writeMessagePart(writeCloser, message, "plain"); err != nil {
return err
}
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentHTML); err != nil {
return fail(FailHTMLHeader, err)
}
if err := service.writeMessagePart(writeCloser, message, "HTML"); err != nil {
return err
}
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, ""); err != nil {
return fail(FailMultiEndHeader, err)
}
return nil
}
// writeMessagePart writes a single part of an email message using the specified template.
func (service *Service) writeMessagePart(
writeCloser io.WriteCloser,
message string,
template string,
) failure {
if tpl, found := service.GetTemplate(template); found {
data := make(map[string]string)
data["message"] = message
if err := tpl.Execute(writeCloser, data); err != nil {
return fail(FailMessageTemplate, err)
}
} else {
if _, err := fmt.Fprint(writeCloser, message); err != nil {
return fail(FailMessageRaw, err)
}
}
return nil
}
// writeMultipartHeader writes a multipart boundary header to the provided writer.
func writeMultipartHeader(writeCloser io.WriteCloser, boundary string, contentType string) error {
suffix := "\n"
if len(contentType) < 1 {
suffix = "--"
}
if _, err := fmt.Fprintf(writeCloser, "\n\n--%s%s", boundary, suffix); err != nil {
return fmt.Errorf("writing multipart boundary: %w", err)
}
if len(contentType) > 0 {
if _, err := fmt.Fprintf(writeCloser, "Content-Type: %s\n\n", contentType); err != nil {
return fmt.Errorf("writing content type header: %w", err)
}
}
return nil
}
// writeHeaders writes email headers to the provided writer.
func writeHeaders(writeCloser io.WriteCloser, headers map[string]string) failure {
for key, val := range headers {
if _, err := fmt.Fprintf(writeCloser, "%s: %s\n", key, val); err != nil {
return fail(FailWriteHeaders, err)
}
}
_, err := fmt.Fprintln(writeCloser)
if err != nil {
return fail(FailWriteHeaders, err)
}
return nil
}

View file

@ -0,0 +1,46 @@
package smtp
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
AuthNone authType = iota // 0
AuthPlain // 1
AuthCRAMMD5 // 2
AuthUnknown // 3
AuthOAuth2 // 4
)
// AuthTypes is the enum helper for populating the Auth field.
var AuthTypes = &authTypeVals{
None: AuthNone,
Plain: AuthPlain,
CRAMMD5: AuthCRAMMD5,
Unknown: AuthUnknown,
OAuth2: AuthOAuth2,
Enum: format.CreateEnumFormatter(
[]string{
"None",
"Plain",
"CRAMMD5",
"Unknown",
"OAuth2",
}),
}
type authType int
type authTypeVals struct {
None authType
Plain authType
CRAMMD5 authType
Unknown authType
OAuth2 authType
Enum types.EnumFormatter
}
func (at authType) String() string {
return AuthTypes.Enum.Print(int(at))
}

View file

@ -0,0 +1,120 @@
package smtp
import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Scheme is the identifying part of this service's configuration URL.
const Scheme = "smtp"
// Static errors for configuration validation.
var (
ErrFromAddressMissing = errors.New("fromAddress missing from config URL")
ErrToAddressMissing = errors.New("toAddress missing from config URL")
)
// Config is the configuration needed to send e-mail notifications over SMTP.
type Config struct {
Host string `desc:"SMTP server hostname or IP address" url:"Host"`
Username string `desc:"SMTP server username" url:"User" default:""`
Password string `desc:"SMTP server password or hash (for OAuth2)" url:"Pass" default:""`
Port uint16 `desc:"SMTP server port, common ones are 25, 465, 587 or 2525" url:"Port" default:"25"`
FromAddress string `desc:"E-mail address that the mail are sent from" key:"fromaddress,from"`
FromName string `desc:"Name of the sender" key:"fromname" optional:"yes"`
ToAddresses []string `desc:"List of recipient e-mails" key:"toaddresses,to"`
Subject string `desc:"The subject of the sent mail" default:"Shoutrrr Notification" key:"subject,title"`
Auth authType `desc:"SMTP authentication method" default:"Unknown" key:"auth"`
Encryption encMethod `desc:"Encryption method" default:"Auto" key:"encryption"`
UseStartTLS bool `desc:"Whether to use StartTLS encryption" default:"Yes" key:"usestarttls,starttls"`
UseHTML bool `desc:"Whether the message being sent is in HTML" default:"No" key:"usehtml"`
ClientHost string `desc:"SMTP client hostname" default:"localhost" key:"clienthost"`
}
// GetURL returns a URL representation of its current field values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates a ServiceConfig 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{
User: util.URLUserPassword(config.Username, config.Password),
Host: fmt.Sprintf("%s:%d", config.Host, config.Port),
Path: "/",
Scheme: Scheme,
ForceQuery: true,
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 {
password, _ := url.User.Password()
config.Username = url.User.Username()
config.Password = password
config.Host = url.Hostname()
if port, err := strconv.ParseUint(url.Port(), 10, 16); err == nil {
config.Port = uint16(port)
}
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)
}
}
if url.String() != "smtp://dummy@dummy.com" {
if len(config.FromAddress) < 1 {
return ErrFromAddressMissing
}
if len(config.ToAddresses) < 1 {
return ErrToAddressMissing
}
}
return nil
}
// Clone returns a copy of the config.
func (config *Config) Clone() Config {
clone := *config
clone.ToAddresses = make([]string, len(config.ToAddresses))
copy(clone.ToAddresses, config.ToAddresses)
return clone
}
// FixEmailTags replaces parsed spaces (+) in e-mail addresses with '+'.
func (config *Config) FixEmailTags() {
config.FromAddress = strings.ReplaceAll(config.FromAddress, " ", "+")
for i, adr := range config.ToAddresses {
config.ToAddresses[i] = strings.ReplaceAll(adr, " ", "+")
}
}
// Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{
"Auth": AuthTypes.Enum,
"Encryption": EncMethods.Enum,
}
}

View file

@ -0,0 +1,71 @@
package smtp
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
// EncNone represents no encryption.
EncNone encMethod = iota // 0
// EncExplicitTLS represents explicit TLS initiated with StartTLS.
EncExplicitTLS // 1
// EncImplicitTLS represents implicit TLS used throughout the session.
EncImplicitTLS // 2
// EncAuto represents automatic TLS selection based on port.
EncAuto // 3
// ImplicitTLSPort is the de facto standard SMTPS port for implicit TLS.
ImplicitTLSPort = 465
)
// EncMethods is the enum helper for populating the Encryption field.
var EncMethods = &encMethodVals{
None: EncNone,
ExplicitTLS: EncExplicitTLS,
ImplicitTLS: EncImplicitTLS,
Auto: EncAuto,
Enum: format.CreateEnumFormatter(
[]string{
"None",
"ExplicitTLS",
"ImplicitTLS",
"Auto",
}),
}
type encMethod int
type encMethodVals struct {
// None means no encryption
None encMethod
// ExplicitTLS means that TLS needs to be initiated by using StartTLS
ExplicitTLS encMethod
// ImplicitTLS means that TLS is used for the whole session
ImplicitTLS encMethod
// Auto means that TLS will be implicitly used for port 465, otherwise explicit TLS will be used if supported
Auto encMethod
// Enum is the EnumFormatter instance for EncMethods
Enum types.EnumFormatter
}
func (at encMethod) String() string {
return EncMethods.Enum.Print(int(at))
}
// useImplicitTLS determines if implicit TLS should be used based on encryption method and port.
func useImplicitTLS(encryption encMethod, port uint16) bool {
switch encryption {
case EncNone:
return false
case EncExplicitTLS:
return false
case EncImplicitTLS:
return true
case EncAuto:
return port == ImplicitTLSPort
default:
return false // Unreachable due to enum constraints, but included for safety
}
}

View file

@ -0,0 +1,104 @@
package smtp
import "github.com/nicholas-fedor/shoutrrr/internal/failures"
const (
// FailUnknown is the default FailureID.
FailUnknown failures.FailureID = iota
// FailGetSMTPClient is returned when a SMTP client could not be created.
FailGetSMTPClient
// FailEnableStartTLS is returned when failing to enable StartTLS.
FailEnableStartTLS
// FailAuthType is returned when the Auth type could not be identified.
FailAuthType
// FailAuthenticating is returned when the authentication fails.
FailAuthenticating
// FailSendRecipient is returned when sending to a recipient fails.
FailSendRecipient
// FailClosingSession is returned when the server doesn't accept the QUIT command.
FailClosingSession
// FailPlainHeader is returned when the text/plain multipart header could not be set.
FailPlainHeader
// FailHTMLHeader is returned when the text/html multipart header could not be set.
FailHTMLHeader
// FailMultiEndHeader is returned when the multipart end header could not be set.
FailMultiEndHeader
// FailMessageTemplate is returned when the message template could not be written to the stream.
FailMessageTemplate
// FailMessageRaw is returned when a non-templated message could not be written to the stream.
FailMessageRaw
// FailSetSender is returned when the server didn't accept the sender address.
FailSetSender
// FailSetRecipient is returned when the server didn't accept the recipient address.
FailSetRecipient
// FailOpenDataStream is returned when the server didn't accept the data stream.
FailOpenDataStream
// FailWriteHeaders is returned when the headers could not be written to the data stream.
FailWriteHeaders
// FailCloseDataStream is returned when the server didn't accept the data stream contents.
FailCloseDataStream
// FailConnectToServer is returned when the TCP connection to the server failed.
FailConnectToServer
// FailCreateSMTPClient is returned when the smtp.Client initialization failed.
FailCreateSMTPClient
// FailApplySendParams is returned when updating the send config failed.
FailApplySendParams
// FailHandshake is returned when the initial HELLO handshake returned an error.
FailHandshake
)
type failure interface {
failures.Failure
}
func fail(failureID failures.FailureID, err error, args ...any) failure {
var msg string
switch failureID {
case FailGetSMTPClient:
msg = "error getting SMTP client"
case FailConnectToServer:
msg = "error connecting to server"
case FailCreateSMTPClient:
msg = "error creating smtp client"
case FailEnableStartTLS:
msg = "error enabling StartTLS"
case FailAuthenticating:
msg = "error authenticating"
case FailAuthType:
msg = "invalid authorization method '%s'"
case FailSendRecipient:
msg = "error sending message to recipient"
case FailClosingSession:
msg = "error closing session"
case FailPlainHeader:
msg = "error writing plain header"
case FailHTMLHeader:
msg = "error writing HTML header"
case FailMultiEndHeader:
msg = "error writing multipart end header"
case FailMessageTemplate:
msg = "error applying message template"
case FailMessageRaw:
msg = "error writing message"
case FailSetSender:
msg = "error creating new message"
case FailSetRecipient:
msg = "error setting RCPT"
case FailOpenDataStream:
msg = "error creating message stream"
case FailWriteHeaders:
msg = "error writing message headers"
case FailCloseDataStream:
msg = "error closing message stream"
case FailApplySendParams:
msg = "error applying params to send config"
case FailHandshake:
msg = "server did not accept the handshake"
// case FailUnknown:
default:
msg = "an unknown error occurred"
}
return failures.Wrap(msg, failureID, err, args...)
}

View file

@ -0,0 +1,25 @@
package smtp
import (
"net/smtp"
)
type oauth2Auth struct {
username, accessToken string
}
// OAuth2Auth returns an Auth that implements the SASL XOAUTH2 authentication
// as per https://developers.google.com/gmail/imap/xoauth2-protocol
func OAuth2Auth(username, accessToken string) smtp.Auth {
return &oauth2Auth{username, accessToken}
}
func (a *oauth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
resp := []byte("user=" + a.username + "\x01auth=Bearer " + a.accessToken + "\x01\x01")
return "XOAUTH2", resp, nil
}
func (a *oauth2Auth) Next(_ []byte, _ bool) ([]byte, error) {
return nil, nil
}

View file

@ -0,0 +1,635 @@
package smtp
import (
"log"
"net/smtp"
"net/url"
"os"
"reflect"
"testing"
"unsafe"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
gomegaTypes "github.com/onsi/gomega/types"
"github.com/nicholas-fedor/shoutrrr/internal/failures"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var tt *testing.T
func TestSMTP(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
tt = t
ginkgo.RunSpecs(t, "Shoutrrr SMTP Suite")
}
var (
service *Service
envSMTPURL string
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
envSMTPURL = os.Getenv("SHOUTRRR_SMTP_URL")
logger = testutils.TestLogger()
})
urlWithAllProps = "smtp://user:password@example.com:2225/?auth=None&clienthost=testhost&encryption=ExplicitTLS&fromaddress=sender%40example.com&fromname=Sender&subject=Subject&toaddresses=rec1%40example.com%2Crec2%40example.com&usehtml=Yes&usestarttls=No"
// BaseNoAuthURL is a minimal SMTP config without authentication.
BaseNoAuthURL = "smtp://example.com:2225/?useStartTLS=no&auth=none&fromAddress=sender@example.com&toAddresses=rec1@example.com&useHTML=no"
// BaseAuthURL is a typical config with authentication.
BaseAuthURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender@example.com&toAddresses=rec1@example.com,rec2@example.com&useHTML=yes"
// BasePlusURL is a config with plus signs in email addresses.
BasePlusURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender+tag@example.com&toAddresses=rec1+tag@example.com,rec2@example.com&useHTML=yes"
)
// modifyURL modifies a base URL by updating query parameters as specified.
func modifyURL(base string, params map[string]string) string {
u := testutils.URLMust(base)
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
return u.String()
}
var _ = ginkgo.Describe("the SMTP service", func() {
ginkgo.BeforeEach(func() {
service = &Service{}
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
url := testutils.URLMust(urlWithAllProps)
config := &Config{}
pkr := format.NewPropKeyResolver(config)
err := config.setURL(&pkr, url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, urlWithAllProps)
gomega.Expect(outputURL.String()).To(gomega.Equal(urlWithAllProps))
})
ginkgo.When("resolving client host", func() {
ginkgo.When("clienthost is set to auto", func() {
ginkgo.It("should return the os hostname", func() {
hostname, err := os.Hostname()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(service.resolveClientHost(&Config{ClientHost: "auto"})).
To(gomega.Equal(hostname))
})
})
ginkgo.When("clienthost is set to a custom value", func() {
ginkgo.It("should return that value", func() {
gomega.Expect(service.resolveClientHost(&Config{ClientHost: "computah"})).
To(gomega.Equal("computah"))
})
})
})
ginkgo.When("fromAddress is missing", func() {
ginkgo.It("should return an error", func() {
testURL := testutils.URLMust(
"smtp://user:password@example.com:2225/?toAddresses=rec1@example.com,rec2@example.com",
)
gomega.Expect((&Config{}).SetURL(testURL)).ToNot(gomega.Succeed())
})
})
ginkgo.When("toAddresses are missing", func() {
ginkgo.It("should return an error", func() {
testURL := testutils.URLMust(
"smtp://user:password@example.com:2225/?fromAddress=sender@example.com",
)
gomega.Expect((&Config{}).SetURL(testURL)).NotTo(gomega.Succeed())
})
})
})
ginkgo.Context("basic service API methods", func() {
var config *Config
ginkgo.BeforeEach(func() {
config = &Config{}
})
ginkgo.It("should not allow getting invalid query values", func() {
testutils.TestConfigGetInvalidQueryValue(config)
})
ginkgo.It("should not allow setting invalid query values", func() {
testutils.TestConfigSetInvalidQueryValue(
config,
"smtp://example.com/?fromAddress=s@example.com&toAddresses=r@example.com&foo=bar",
)
})
ginkgo.It("should have the expected number of fields and enums", func() {
testutils.TestConfigGetEnumsCount(config, 2)
testutils.TestConfigGetFieldsCount(config, 13)
})
})
ginkgo.When("cloning a config", func() {
ginkgo.It("should be identical to the original", func() {
config := &Config{}
gomega.Expect(config.SetURL(testutils.URLMust(urlWithAllProps))).To(gomega.Succeed())
gomega.Expect(config.Clone()).To(gomega.Equal(*config))
})
})
ginkgo.When("sending a message", func() {
ginkgo.When("the service is not configured correctly", func() {
ginkgo.It("should fail to send messages", func() {
service := Service{Config: &Config{}}
gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient))
service.Config.Encryption = EncMethods.ImplicitTLS
gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient))
})
})
ginkgo.When("an invalid param is passed", func() {
ginkgo.It("should fail to send messages", func() {
service := Service{Config: &Config{}}
gomega.Expect(service.Send("test message", &types.Params{"invalid": "value"})).
To(matchFailure(FailApplySendParams))
})
})
})
ginkgo.When("the underlying stream stops working", func() {
var service Service
var message string
ginkgo.BeforeEach(func() {
service = Service{}
message = ""
})
ginkgo.It("should fail when writing multipart plain header", func() {
writer := testutils.CreateFailWriter(1)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailPlainHeader))
})
ginkgo.It("should fail when writing multipart plain message", func() {
writer := testutils.CreateFailWriter(2)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw))
})
ginkgo.It("should fail when writing multipart HTML header", func() {
writer := testutils.CreateFailWriter(4)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailHTMLHeader))
})
ginkgo.It("should fail when writing multipart HTML message", func() {
writer := testutils.CreateFailWriter(5)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw))
})
ginkgo.It("should fail when writing multipart end header", func() {
writer := testutils.CreateFailWriter(6)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMultiEndHeader))
})
ginkgo.It("should fail when writing message template", func() {
writer := testutils.CreateFailWriter(0)
e := service.SetTemplateString("dummy", "dummy template content")
gomega.Expect(e).ToNot(gomega.HaveOccurred())
err := service.writeMessagePart(writer, message, "dummy")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageTemplate))
})
})
ginkgo.When("running E2E tests", func() {
ginkgo.It("should work without errors", func() {
if envSMTPURL == "" {
ginkgo.Skip("environment not set up for E2E testing")
return
}
serviceURL, err := url.Parse(envSMTPURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("running integration tests", func() {
ginkgo.When("given a typical usage case configuration URL", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BaseAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"235 Accepted",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "<pre>{{ .message }}</pre>", "{{ .message }}")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given e-mail addresses with pluses in the configuration URL", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BasePlusURL
err := testIntegration(
testURL,
[]string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"235 Accepted",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
},
"<pre>{{ .message }}</pre>", "{{ .message }}",
"RCPT TO:<rec1+tag@example.com>",
"To: rec1+tag@example.com",
"From: <sender+tag@example.com>")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given a configuration URL with authentication disabled", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given a configuration URL with StartTLS but it is not supported", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("server communication fails", func() {
ginkgo.It("should fail when initial handshake is not accepted", func() {
testURL := modifyURL(
BaseNoAuthURL,
map[string]string{"useStartTLS": "yes", "clienthost": "spammer"},
)
err := testIntegration(testURL, []string{
"421 4.7.0 Try again later, closing connection. (EHLO) r20-20020a50d694000000b004588af8956dsm771862edi.9 - gsmtp",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(gomega.MatchError(fail(FailHandshake, nil)))
})
ginkgo.It("should fail when not being able to enable StartTLS", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-STARTTLS",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"502 That's too hard",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailEnableStartTLS))
})
ginkgo.It("should fail when authentication type is invalid", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "bad"})
err := testIntegration(testURL, []string{}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(standard.FailServiceInit))
})
ginkgo.It("should fail when not being able to use authentication type", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "crammd5"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"504 Liar",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailAuthenticating))
})
ginkgo.It("should fail when not being able to send to recipient", func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"551 I don't know you",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailSendRecipient))
})
ginkgo.It("should fail when the recipient is not accepted", func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"553 She doesn't want to be disturbed",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailSetRecipient))
})
ginkgo.It("should fail when the server does not accept the data stream", func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"250 Receiver OK",
"554 Nah I'm fine thanks",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailOpenDataStream))
})
ginkgo.It(
"should fail when the server does not accept the data stream content",
func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"554 Such garbage!",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailCloseDataStream))
},
)
ginkgo.It(
"should fail when the server does not close the connection gracefully",
func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"502 You can't quit, you're fired!",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailClosingSession))
},
)
})
})
ginkgo.When("writing headers and the output stream is closed", func() {
ginkgo.When("it's closed during header content", func() {
ginkgo.It("should fail with correct error", func() {
fw := testutils.CreateFailWriter(0)
gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})).
To(matchFailure(FailWriteHeaders))
})
})
ginkgo.When("it's closed after header content", func() {
ginkgo.It("should fail with correct error", func() {
fw := testutils.CreateFailWriter(1)
gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})).
To(matchFailure(FailWriteHeaders))
})
})
})
ginkgo.When("default port is not specified", func() {
ginkgo.It("should use the default SMTP port when not specified", func() {
testURL := "smtp://example.com/?fromAddress=sender@example.com&toAddresses=rec1@example.com"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Port).To(gomega.Equal(uint16(DefaultSMTPPort)))
})
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("smtp"))
})
})
func testSendRecipient(testURL string, responses []string) failures.Failure {
serviceURL, err := url.Parse(testURL)
if err != nil {
return standard.Failure(standard.FailParseURL, err)
}
err = service.Initialize(serviceURL, logger)
if err != nil {
return failures.Wrap("error parsing URL", standard.FailTestSetup, err)
}
if err := service.SetTemplateString("plain", "{{.message}}"); err != nil {
return failures.Wrap("error setting plain template", standard.FailTestSetup, err)
}
textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n")
client := &smtp.Client{
Text: textCon,
}
fakeTLSEnabled(client, serviceURL.Hostname())
config := &Config{}
message := "message body"
ferr := service.sendToRecipient(client, "r@example.com", config, message)
logger.Printf("\n%s", tcfaker.GetConversation(false))
if ferr != nil {
return ferr
}
return nil
}
func testIntegration(
testURL string,
responses []string,
htmlTemplate string,
plainTemplate string,
expectRec ...string,
) failures.Failure {
serviceURL, err := url.Parse(testURL)
if err != nil {
return standard.Failure(standard.FailParseURL, err)
}
if err = service.Initialize(serviceURL, logger); err != nil {
return standard.Failure(standard.FailServiceInit, err)
}
if htmlTemplate != "" {
if err := service.SetTemplateString("HTML", htmlTemplate); err != nil {
return failures.Wrap("error setting HTML template", standard.FailTestSetup, err)
}
}
if plainTemplate != "" {
if err := service.SetTemplateString("plain", plainTemplate); err != nil {
return failures.Wrap("error setting plain template", standard.FailTestSetup, err)
}
}
textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n")
client := &smtp.Client{
Text: textCon,
}
fakeTLSEnabled(client, serviceURL.Hostname())
ferr := service.doSend(client, "Test message", service.Config)
received := tcfaker.GetClientSentences()
for _, expected := range expectRec {
gomega.Expect(received).To(gomega.ContainElement(expected))
}
logger.Printf("\n%s", tcfaker.GetConversation(false))
if ferr != nil {
return ferr
}
return nil
}
// fakeTLSEnabled tricks a given client into believing that TLS is enabled even though it's not
// this is needed because the SMTP library won't allow plain authentication without TLS being turned on.
// having it turned on would of course mean that we cannot test the communication since it will be encrypted.
func fakeTLSEnabled(client *smtp.Client, hostname string) {
// set the "tls" flag on the client which indicates that TLS encryption is enabled (even though it's not)
cr := reflect.ValueOf(client).Elem().FieldByName("tls")
cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem()
cr.SetBool(true)
// set the serverName field on the client which is used to identify the server and has to equal the hostname
cr = reflect.ValueOf(client).Elem().FieldByName("serverName")
cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem()
cr.SetString(hostname)
}
// matchFailure is a simple wrapper around `fail` and `gomega.MatchError` to make it easier to use in tests.
func matchFailure(id failures.FailureID) gomegaTypes.GomegaMatcher {
return gomega.MatchError(fail(id, nil))
}

View file

@ -0,0 +1,13 @@
package standard
import (
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// EnumlessConfig implements the ServiceConfig interface for services that does not use Enum fields.
type EnumlessConfig struct{}
// Enums returns an empty map.
func (ec *EnumlessConfig) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}

View file

@ -0,0 +1,7 @@
package standard
// Standard implements the Logger and Templater parts of the Service interface.
type Standard struct {
Logger
Templater
}

View file

@ -0,0 +1,59 @@
package standard
import (
"fmt"
"github.com/nicholas-fedor/shoutrrr/internal/failures"
)
const (
// FailTestSetup is the FailureID used to represent an error that is part of the setup for tests.
FailTestSetup failures.FailureID = -1
// FailParseURL is the FailureID used to represent failing to parse the service URL.
FailParseURL failures.FailureID = -2
// FailServiceInit is the FailureID used to represent failure of a service.Initialize method.
FailServiceInit failures.FailureID = -3
// FailUnknown is the default FailureID.
FailUnknown failures.FailureID = iota
)
type failureLike interface {
failures.Failure
}
// Failure creates a Failure instance corresponding to the provided failureID, wrapping the provided error.
func Failure(failureID failures.FailureID, err error, args ...any) failures.Failure {
messages := map[int]string{
int(FailTestSetup): "test setup failed",
int(FailParseURL): "error parsing Service URL",
int(FailUnknown): "an unknown error occurred",
}
msg := messages[int(failureID)]
if msg == "" {
msg = messages[int(FailUnknown)]
}
// If variadic arguments are provided, format them correctly
if len(args) > 0 {
if format, ok := args[0].(string); ok && len(args) > 1 {
// Treat the first argument as a format string and the rest as its arguments
extraMsg := fmt.Sprintf(format, args[1:]...)
msg = fmt.Sprintf("%s %s", msg, extraMsg)
} else {
// If no format string is provided, just append the arguments as-is
msg = fmt.Sprintf("%s %v", msg, args)
}
}
return failures.Wrap(msg, failureID, err)
}
// IsTestSetupFailure checks whether the given failure is due to the test setup being broken.
func IsTestSetupFailure(failure failureLike) (string, bool) {
if failure != nil && failure.ID() == FailTestSetup {
return "test setup failed: " + failure.Error(), true
}
return "", false
}

View file

@ -0,0 +1,30 @@
package standard
import (
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Logger provides the utility methods Log* that maps to Logger.Print*.
type Logger struct {
logger types.StdLogger
}
// Logf maps to the service loggers Logger.Printf function.
func (sl *Logger) Logf(format string, v ...any) {
sl.logger.Printf(format, v...)
}
// Log maps to the service loggers Logger.Print function.
func (sl *Logger) Log(v ...any) {
sl.logger.Print(v...)
}
// SetLogger maps the specified logger to the Log* helper methods.
func (sl *Logger) SetLogger(logger types.StdLogger) {
if logger == nil {
sl.logger = util.DiscardLogger
} else {
sl.logger = logger
}
}

View file

@ -0,0 +1,45 @@
package standard
import (
"fmt"
"os"
"text/template"
)
// Templater is the standard implementation of ApplyTemplate using the "text/template" library.
type Templater struct {
templates map[string]*template.Template
}
// GetTemplate attempts to retrieve the template identified with id.
func (templater *Templater) GetTemplate(id string) (*template.Template, bool) {
tpl, found := templater.templates[id]
return tpl, found
}
// SetTemplateString creates a new template from the body and assigns it the id.
func (templater *Templater) SetTemplateString(templateID string, body string) error {
tpl, err := template.New("").Parse(body)
if err != nil {
return fmt.Errorf("parsing template string for ID %q: %w", templateID, err)
}
if templater.templates == nil {
templater.templates = make(map[string]*template.Template, 1)
}
templater.templates[templateID] = tpl
return nil
}
// SetTemplateFile creates a new template from the file and assigns it the id.
func (templater *Templater) SetTemplateFile(templateID string, file string) error {
bytes, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("reading template file %q for ID %q: %w", file, templateID, err)
}
return templater.SetTemplateString(templateID, string(bytes))
}

View file

@ -0,0 +1,205 @@
package standard
import (
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/failures"
)
func TestStandard(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Standard Suite")
}
var (
logger *Logger
builder *strings.Builder
stringLogger *log.Logger
)
var _ = ginkgo.Describe("the standard logging implementation", func() {
ginkgo.When("setlogger is called with nil", func() {
ginkgo.It("should provide the logging API without any errors", func() {
logger = &Logger{}
logger.SetLogger(nil)
logger.Log("discarded log message")
gomega.Expect(logger.logger).ToNot(gomega.BeNil())
})
})
ginkgo.When("setlogger is called with a proper logger", func() {
ginkgo.BeforeEach(func() {
logger = &Logger{}
builder = &strings.Builder{}
stringLogger = log.New(builder, "", 0)
})
ginkgo.When("when logger.Log is called", func() {
ginkgo.It("should log messages", func() {
logger.SetLogger(stringLogger)
logger.Log("foo")
logger.Log("bar")
gomega.Expect(builder.String()).To(gomega.Equal("foo\nbar\n"))
})
})
ginkgo.When("when logger.Logf is called", func() {
ginkgo.It("should log messages", func() {
logger.SetLogger(stringLogger)
logger.Logf("foo %d", 7)
gomega.Expect(builder.String()).To(gomega.Equal("foo 7\n"))
})
})
})
})
var _ = ginkgo.Describe("the standard template implementation", func() {
ginkgo.When("a template is being set from a file", func() {
ginkgo.It("should load the template without any errors", func() {
file, err := os.CreateTemp("", "")
if err != nil {
ginkgo.Skip(fmt.Sprintf("Could not create temp file: %s", err))
return
}
fileName := file.Name()
defer os.Remove(fileName)
_, err = io.WriteString(file, "template content")
if err != nil {
ginkgo.Skip(fmt.Sprintf("Could not write to temp file: %s", err))
return
}
templater := &Templater{}
err = templater.SetTemplateFile("foo", fileName)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
})
ginkgo.When("a template is being set from a file that does not exist", func() {
ginkgo.It("should return an error", func() {
templater := &Templater{}
err := templater.SetTemplateFile("foo", "filename_that_should_not_exist")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("a template is being set with a badly formatted string", func() {
ginkgo.It("should return an error", func() {
templater := &Templater{}
err := templater.SetTemplateString("foo", "template {{ missing end tag")
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.When("a template is being retrieved with a present ID", func() {
ginkgo.It("should return the corresponding template", func() {
templater := &Templater{}
err := templater.SetTemplateString("bar", "template body")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
tpl, found := templater.GetTemplate("bar")
gomega.Expect(tpl).ToNot(gomega.BeNil())
gomega.Expect(found).To(gomega.BeTrue())
})
})
ginkgo.When("a template is being retrieved with an invalid ID", func() {
ginkgo.It("should return an error", func() {
templater := &Templater{}
err := templater.SetTemplateString("bar", "template body")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
tpl, found := templater.GetTemplate("bad ID")
gomega.Expect(tpl).To(gomega.BeNil())
gomega.Expect(found).ToNot(gomega.BeTrue())
})
})
})
var _ = ginkgo.Describe("the standard enumless config implementation", func() {
ginkgo.When("it's enum method is called", func() {
ginkgo.It("should return an empty map", func() {
gomega.Expect((&EnumlessConfig{}).Enums()).To(gomega.BeEmpty())
})
})
})
var _ = ginkgo.Describe("the standard failure implementation", func() {
ginkgo.Describe("Failure function", func() {
ginkgo.When("called with FailParseURL", func() {
ginkgo.It("should return a failure with the correct message", func() {
err := errors.New("invalid URL")
failure := Failure(FailParseURL, err)
gomega.Expect(failure.ID()).To(gomega.Equal(FailParseURL))
gomega.Expect(failure.Error()).
To(gomega.ContainSubstring("error parsing Service URL"))
gomega.Expect(failure.Error()).To(gomega.ContainSubstring("invalid URL"))
})
})
ginkgo.When("called with FailUnknown", func() {
ginkgo.It("should return a failure with the unknown error message", func() {
err := errors.New("something went wrong")
failure := Failure(FailUnknown, err)
gomega.Expect(failure.ID()).To(gomega.Equal(FailUnknown))
gomega.Expect(failure.Error()).
To(gomega.ContainSubstring("an unknown error occurred"))
gomega.Expect(failure.Error()).To(gomega.ContainSubstring("something went wrong"))
})
})
ginkgo.When("called with an unrecognized FailureID", func() {
ginkgo.It("should fallback to the unknown error message", func() {
err := errors.New("unrecognized error")
failure := Failure(failures.FailureID(999), err) // Arbitrary unknown ID
gomega.Expect(failure.ID()).To(gomega.Equal(failures.FailureID(999)))
gomega.Expect(failure.Error()).
To(gomega.ContainSubstring("an unknown error occurred"))
gomega.Expect(failure.Error()).To(gomega.ContainSubstring("unrecognized error"))
})
})
ginkgo.When("called with additional arguments", func() {
ginkgo.It("should include formatted arguments in the error", func() {
err := errors.New("base error")
failure := Failure(FailParseURL, err, "extra info: %s", "details")
gomega.Expect(failure.Error()).
To(gomega.ContainSubstring("error parsing Service URL extra info: details"))
gomega.Expect(failure.Error()).To(gomega.ContainSubstring("base error"))
})
})
})
ginkgo.Describe("IsTestSetupFailure function", func() {
ginkgo.When("called with a FailTestSetup failure", func() {
ginkgo.It("should return true and the correct message", func() {
err := errors.New("setup issue")
failure := Failure(FailTestSetup, err)
msg, isSetupFailure := IsTestSetupFailure(failure)
gomega.Expect(isSetupFailure).To(gomega.BeTrue())
gomega.Expect(msg).To(gomega.ContainSubstring("test setup failed: setup issue"))
})
})
ginkgo.When("called with a different failure", func() {
ginkgo.It("should return false and an empty message", func() {
err := errors.New("parse issue")
failure := Failure(FailParseURL, err)
msg, isSetupFailure := IsTestSetupFailure(failure)
gomega.Expect(isSetupFailure).To(gomega.BeFalse())
gomega.Expect(msg).To(gomega.BeEmpty())
})
})
ginkgo.When("called with nil", func() {
ginkgo.It("should return false and an empty message", func() {
msg, isSetupFailure := IsTestSetupFailure(nil)
gomega.Expect(isSetupFailure).To(gomega.BeFalse())
gomega.Expect(msg).To(gomega.BeEmpty())
})
})
})
})

View file

@ -0,0 +1,200 @@
package teams
import (
"fmt"
"net/url"
"regexp"
"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 identifier for the Teams service protocol.
const Scheme = "teams"
// Config constants.
const (
DummyURL = "teams://dummy@dummy.com" // Default placeholder URL
ExpectedOrgMatches = 2 // Full match plus organization domain capture group
MinPathComponents = 3 // Minimum required path components: AltID, GroupOwner, ExtraID
)
// Config represents the configuration for the Teams service.
type Config struct {
standard.EnumlessConfig
Group string `optional:"" url:"user"`
Tenant string `optional:"" url:"host"`
AltID string `optional:"" url:"path1"`
GroupOwner string `optional:"" url:"path2"`
ExtraID string `optional:"" url:"path3"`
Title string `key:"title" optional:""`
Color string `key:"color" optional:""`
Host string `key:"host" optional:""` // Required, no default
}
// WebhookParts returns the webhook components as an array.
func (config *Config) WebhookParts() [5]string {
return [5]string{config.Group, config.Tenant, config.AltID, config.GroupOwner, config.ExtraID}
}
// SetFromWebhookURL updates the Config from a Teams webhook URL.
func (config *Config) SetFromWebhookURL(webhookURL string) error {
orgPattern := regexp.MustCompile(
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
)
orgGroups := orgPattern.FindStringSubmatch(webhookURL)
if len(orgGroups) != ExpectedComponents {
return ErrInvalidWebhookFormat
}
config.Host = orgGroups[1] + WebhookDomain
parts, err := ParseAndVerifyWebhookURL(webhookURL)
if err != nil {
return err
}
config.setFromWebhookParts(parts)
return nil
}
// ConfigFromWebhookURL creates a new Config from a parsed Teams webhook URL.
func ConfigFromWebhookURL(webhookURL url.URL) (*Config, error) {
webhookURL.RawQuery = ""
config := &Config{Host: webhookURL.Host}
if err := config.SetFromWebhookURL(webhookURL.String()); err != nil {
return nil, err
}
return config, nil
}
// GetURL constructs a URL from the Config fields.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// getURL constructs a URL using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
if config.Host == "" {
return nil
}
return &url.URL{
User: url.User(config.Group),
Host: config.Tenant,
Path: "/" + config.AltID + "/" + config.GroupOwner + "/" + config.ExtraID,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
}
}
// SetURL updates the Config from a URL.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// setURL updates the Config from a URL using the provided resolver.
// It parses the URL parts, sets query parameters, and ensures the host is specified.
// Returns an error if the URL is invalid or the host is missing.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
parts, err := parseURLParts(url)
if err != nil {
return err
}
config.setFromWebhookParts(parts)
if err := config.setQueryParams(resolver, url.Query()); err != nil {
return err
}
if config.Host == "" {
return ErrMissingHostParameter
}
return nil
}
// parseURLParts extracts and validates webhook components from a URL.
func parseURLParts(url *url.URL) ([5]string, error) {
var parts [5]string
if url.String() == DummyURL {
return parts, nil
}
pathParts := strings.Split(url.Path, "/")
if pathParts[0] == "" {
pathParts = pathParts[1:]
}
if len(pathParts) < MinPathComponents {
return parts, ErrMissingExtraIDComponent
}
parts = [5]string{
url.User.Username(),
url.Hostname(),
pathParts[0],
pathParts[1],
pathParts[2],
}
if err := verifyWebhookParts(parts); err != nil {
return parts, fmt.Errorf("invalid URL format: %w", err)
}
return parts, nil
}
// setQueryParams applies query parameters to the Config using the resolver.
// It resets Color, Host, and Title, then updates them based on query values.
// Returns an error if the resolver fails to set any parameter.
func (config *Config) setQueryParams(resolver types.ConfigQueryResolver, query url.Values) error {
config.Color = ""
config.Host = ""
config.Title = ""
for key, vals := range query {
if len(vals) > 0 && vals[0] != "" {
switch key {
case "color":
config.Color = vals[0]
case "host":
config.Host = vals[0]
case "title":
config.Title = vals[0]
}
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf(
"%w: key=%q, value=%q: %w",
ErrSetParameterFailed,
key,
vals[0],
err,
)
}
}
}
return nil
}
// setFromWebhookParts sets Config fields from webhook parts.
func (config *Config) setFromWebhookParts(parts [5]string) {
config.Group = parts[0]
config.Tenant = parts[1]
config.AltID = parts[2]
config.GroupOwner = parts[3]
config.ExtraID = parts[4]
}

View file

@ -0,0 +1,50 @@
package teams
import "errors"
// Error variables for the Teams package.
var (
// ErrInvalidWebhookFormat indicates the webhook URL doesn't contain the organization domain.
ErrInvalidWebhookFormat = errors.New(
"invalid webhook URL format - must contain organization domain",
)
// ErrMissingHostParameter indicates the required host parameter is missing.
ErrMissingHostParameter = errors.New(
"missing required host parameter (organization.webhook.office.com)",
)
// ErrMissingExtraIDComponent indicates the URL is missing the extraId component.
ErrMissingExtraIDComponent = errors.New("invalid URL format: missing extraId component")
// ErrMissingHost indicates the host is not specified in the configuration.
ErrMissingHost = errors.New("host is required but not specified in the configuration")
// ErrSetParameterFailed indicates failure to set a configuration parameter.
ErrSetParameterFailed = errors.New("failed to set configuration parameter")
// ErrSendFailedStatus indicates an unexpected status code in the response.
ErrSendFailedStatus = errors.New(
"failed to send notification to teams, response status code unexpected",
)
// ErrSendFailed indicates a general failure in sending the notification.
ErrSendFailed = errors.New("an error occurred while sending notification to teams")
// ErrInvalidWebhookURL indicates the webhook URL format is invalid.
ErrInvalidWebhookURL = errors.New("invalid webhook URL format")
// ErrInvalidHostFormat indicates the host format is invalid.
ErrInvalidHostFormat = errors.New("invalid host format")
// ErrInvalidWebhookComponents indicates a mismatch in expected webhook URL components.
ErrInvalidWebhookComponents = errors.New(
"invalid webhook URL format: expected component count mismatch",
)
// ErrInvalidPartLength indicates a webhook component has an incorrect length.
ErrInvalidPartLength = errors.New("invalid webhook part length")
// ErrMissingExtraID indicates the extraID is missing.
ErrMissingExtraID = errors.New("extraID is required")
)

View file

@ -0,0 +1,164 @@
package teams
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"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"
)
// MaxSummaryLength defines the maximum length for a notification summary.
const MaxSummaryLength = 20
// TruncatedSummaryLen defines the length for a truncated summary.
const TruncatedSummaryLen = 21
// Service sends notifications to Microsoft Teams.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Microsoft Teams.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
service.Logf("Failed to update params: %v", err)
}
return service.doSend(config, message)
}
// 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{}
service.pkr = format.NewPropKeyResolver(service.Config)
return service.Config.SetURL(configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// GetConfigURLFromCustom converts a custom URL to a service URL.
func (service *Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) {
webhookURLStr := strings.TrimPrefix(customURL.String(), "teams+")
tempURL, err := url.Parse(webhookURLStr)
if err != nil {
return nil, fmt.Errorf("parsing custom URL %q: %w", webhookURLStr, err)
}
webhookURL := &url.URL{
Scheme: tempURL.Scheme,
Host: tempURL.Host,
Path: tempURL.Path,
}
config, err := ConfigFromWebhookURL(*webhookURL)
if err != nil {
return nil, err
}
config.Color = ""
config.Title = ""
query := customURL.Query()
for key, vals := range query {
if vals[0] != "" {
switch key {
case "color":
config.Color = vals[0]
case "host":
config.Host = vals[0]
case "title":
config.Title = vals[0]
}
}
}
return config.GetURL(), nil
}
// doSend sends the notification to Teams using the configured webhook URL.
func (service *Service) doSend(config *Config, message string) error {
lines := strings.Split(message, "\n")
sections := make([]section, 0, len(lines))
for _, line := range lines {
sections = append(sections, section{Text: line})
}
summary := config.Title
if summary == "" && len(sections) > 0 {
summary = sections[0].Text
if len(summary) > MaxSummaryLength {
summary = summary[:TruncatedSummaryLen]
}
}
payload, err := json.Marshal(payload{
CardType: "MessageCard",
Context: "http://schema.org/extensions",
Markdown: true,
Title: config.Title,
ThemeColor: config.Color,
Summary: summary,
Sections: sections,
})
if err != nil {
return fmt.Errorf("marshaling payload to JSON: %w", err)
}
if config.Host == "" {
return ErrMissingHost
}
postURL := BuildWebhookURL(
config.Host,
config.Group,
config.Tenant,
config.AltID,
config.GroupOwner,
config.ExtraID,
)
// Validate URL before sending
if err := ValidateWebhookURL(postURL); err != nil {
return err
}
res, err := safePost(postURL, payload)
if err != nil {
return fmt.Errorf("%w: %s", ErrSendFailed, err.Error())
}
defer res.Body.Close() // Move defer after error check
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %s", ErrSendFailedStatus, res.Status)
}
return nil
}
// safePost performs an HTTP POST with a pre-validated URL.
// Validation is already done; this wrapper isolates the call.
//
//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url
func safePost(url string, payload []byte) (*http.Response, error) {
res, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("making HTTP POST request: %w", err)
}
return res, nil
}

View file

@ -0,0 +1,265 @@
package teams
import (
"errors"
"log"
"net/url"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
const (
extraIDValue = "V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"
scopedWebhookURL = "https://test.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
scopedDomainHost = "test.webhook.office.com"
testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
scopedURLBase = testURLBase + "?host=" + scopedDomainHost
)
var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
// TestTeams runs the test suite for the Teams package.
func TestTeams(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Teams Suite")
}
var _ = ginkgo.Describe("the teams service", func() {
ginkgo.When("creating the webhook URL", func() {
ginkgo.It("should match the expected output for custom URLs", func() {
config := Config{}
config.setFromWebhookParts([5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
})
apiURL := BuildWebhookURL(
scopedDomainHost,
config.Group,
config.Tenant,
config.AltID,
config.GroupOwner,
config.ExtraID,
)
gomega.Expect(apiURL).To(gomega.Equal(scopedWebhookURL))
parts, err := ParseAndVerifyWebhookURL(apiURL)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(parts).To(gomega.Equal(config.WebhookParts()))
})
})
ginkgo.Describe("creating a config", func() {
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := testURLBase + "?color=aabbcc&host=test.webhook.office.com&title=Test+title"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.Describe("converting custom URL to service URL", func() {
ginkgo.When("an invalid custom URL is provided", func() {
ginkgo.It("should return an error", func() {
service := Service{}
testURL := "teams+https://google.com/search?q=what+is+love"
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
_, err = service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).To(gomega.HaveOccurred(), "converting")
})
})
ginkgo.When("a valid custom URL is provided", func() {
ginkgo.It("should set the host field from the custom URL", func() {
service := Service{}
testURL := `teams+` + scopedWebhookURL
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
serviceURL, err := service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
gomega.Expect(serviceURL.String()).To(gomega.Equal(scopedURLBase))
})
ginkgo.It("should preserve the query params in the generated service URL", func() {
service := Service{}
testURL := "teams+" + scopedWebhookURL + "?color=f008c1&title=TheTitle"
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
serviceURL, err := service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
expectedURL := testURLBase + "?color=f008c1&host=test.webhook.office.com&title=TheTitle"
gomega.Expect(serviceURL.String()).To(gomega.Equal(expectedURL))
})
})
})
ginkgo.Describe("sending the payload", func() {
var err error
var service Service
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL, _ := url.Parse(scopedURLBase)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
scopedWebhookURL,
httpmock.NewStringResponder(200, ""),
)
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() {
serviceURL, _ := url.Parse(testURLBase + "?host=test.webhook.office.com")
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
scopedWebhookURL,
httpmock.NewErrorResponder(errors.New("dummy error")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("teams"))
})
// Config tests
ginkgo.Describe("the teams config", func() {
ginkgo.Describe("setURL", func() {
ginkgo.It("should set all fields correctly from URL", func() {
config := &Config{}
urlStr := testURLBase + "?title=Test&color=red&host=test.webhook.office.com"
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Group).To(gomega.Equal("11111111-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.Tenant).
To(gomega.Equal("22222222-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.AltID).To(gomega.Equal("33333301222222222233333333333344"))
gomega.Expect(config.GroupOwner).
To(gomega.Equal("44444444-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.ExtraID).To(gomega.Equal(extraIDValue))
gomega.Expect(config.Title).To(gomega.Equal("Test"))
gomega.Expect(config.Color).To(gomega.Equal("red"))
gomega.Expect(config.Host).To(gomega.Equal("test.webhook.office.com"))
})
ginkgo.It("should reject URLs missing the extraID", func() {
config := &Config{}
urlStr := "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc?host=test.webhook.office.com"
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should require the host parameter", func() {
config := &Config{}
urlStr := testURLBase
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("getURL", func() {
ginkgo.It("should generate correct URL with all parameters", func() {
config := &Config{
Group: "11111111-4444-4444-8444-cccccccccccc",
Tenant: "22222222-4444-4444-8444-cccccccccccc",
AltID: "33333301222222222233333333333344",
GroupOwner: "44444444-4444-4444-8444-cccccccccccc",
ExtraID: extraIDValue,
Title: "Test",
Color: "red",
Host: "test.webhook.office.com",
}
urlObj := config.GetURL()
urlStr := urlObj.String()
expectedURL := testURLBase + "?color=red&host=test.webhook.office.com&title=Test"
gomega.Expect(urlStr).To(gomega.Equal(expectedURL))
})
})
ginkgo.Describe("verifyWebhookParts", func() {
ginkgo.It("should validate correct webhook parts", func() {
parts := [5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}
err := verifyWebhookParts(parts)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should reject invalid group ID", func() {
parts := [5]string{
"invalid-id",
"22222222-4444-4444-8444-cccccccccccc",
"33333333012222222222333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}
err := verifyWebhookParts(parts)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("parseAndVerifyWebhookURL", func() {
ginkgo.It("should correctly parse valid webhook URL", func() {
webhookURL := scopedWebhookURL
parts, err := ParseAndVerifyWebhookURL(webhookURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(parts).To(gomega.Equal([5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}))
})
ginkgo.It("should reject invalid webhook URL", func() {
webhookURL := "https://teams.microsoft.com/invalid/webhook/url"
_, err := ParseAndVerifyWebhookURL(webhookURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
})

View file

@ -0,0 +1,62 @@
package teams
// payload is the main structure for a Teams message card.
type payload struct {
CardType string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor,omitempty"`
Summary string `json:"summary"`
Title string `json:"title,omitempty"`
Markdown bool `json:"markdown"`
Sections []section `json:"sections"`
}
// section represents a section of a Teams message card.
type section struct {
ActivityTitle string `json:"activityTitle,omitempty"`
ActivitySubtitle string `json:"activitySubtitle,omitempty"`
ActivityImage string `json:"activityImage,omitempty"`
Facts []fact `json:"facts,omitempty"`
Text string `json:"text,omitempty"`
Images []image `json:"images,omitempty"`
Actions []action `json:"potentialAction,omitempty"`
HeroImage *heroCard `json:"heroImage,omitempty"`
}
// fact represents a key-value pair in a Teams message card.
type fact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// image represents an image in a Teams message card.
type image struct {
Image string `json:"image"`
Title string `json:"title,omitempty"`
}
// action represents an action button in a Teams message card.
type action struct {
Type string `json:"@type"`
Name string `json:"name"`
Targets []target `json:"targets,omitempty"`
Actions []subAction `json:"actions,omitempty"`
}
// target represents a target for an action in a Teams message card.
type target struct {
OS string `json:"os"`
URI string `json:"uri"`
}
// subAction represents a sub-action in a Teams message card.
type subAction struct {
Type string `json:"@type"`
Name string `json:"name"`
URI string `json:"uri"`
}
// heroCard represents a hero image in a Teams message card.
type heroCard struct {
Image string `json:"image"`
}

View file

@ -0,0 +1,107 @@
package teams
import (
"fmt"
"regexp"
)
// Validation constants.
const (
UUID4Length = 36 // Length of a UUID4 identifier
HashLength = 32 // Length of a hash identifier
WebhookDomain = ".webhook.office.com"
ExpectedComponents = 7 // Expected number of components in webhook URL (1 match + 6 captures)
Path = "webhookb2"
ProviderName = "IncomingWebhook"
AltIDIndex = 2 // Index of AltID in parts array
GroupOwnerIndex = 3 // Index of GroupOwner in parts array
)
var (
// HostValidator ensures the host matches the Teams webhook domain pattern.
HostValidator = regexp.MustCompile(`^[a-zA-Z0-9-]+\.webhook\.office\.com$`)
// WebhookURLValidator ensures the full webhook URL matches the Teams pattern.
WebhookURLValidator = regexp.MustCompile(
`^https://[a-zA-Z0-9-]+\.webhook\.office\.com/webhookb2/[0-9a-f-]{36}@[0-9a-f-]{36}/IncomingWebhook/[0-9a-f]{32}/[0-9a-f-]{36}/[^/]+$`,
)
)
// ValidateWebhookURL ensures the webhook URL is valid before use.
func ValidateWebhookURL(url string) error {
if !WebhookURLValidator.MatchString(url) {
return fmt.Errorf("%w: %q", ErrInvalidWebhookURL, url)
}
return nil
}
// ParseAndVerifyWebhookURL extracts and validates webhook components from a URL.
func ParseAndVerifyWebhookURL(webhookURL string) ([5]string, error) {
pattern := regexp.MustCompile(
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
)
groups := pattern.FindStringSubmatch(webhookURL)
if len(groups) != ExpectedComponents {
return [5]string{}, fmt.Errorf(
"%w: expected %d components, got %d",
ErrInvalidWebhookComponents,
ExpectedComponents,
len(groups),
)
}
parts := [5]string{groups[2], groups[3], groups[4], groups[5], groups[6]}
if err := verifyWebhookParts(parts); err != nil {
return [5]string{}, err
}
return parts, nil
}
// verifyWebhookParts ensures webhook components meet format requirements.
func verifyWebhookParts(parts [5]string) error {
type partSpec struct {
name string
length int
index int
optional bool
}
specs := []partSpec{
{name: "group ID", length: UUID4Length, index: 0, optional: true},
{name: "tenant ID", length: UUID4Length, index: 1, optional: true},
{name: "altID", length: HashLength, index: AltIDIndex, optional: true},
{name: "groupOwner", length: UUID4Length, index: GroupOwnerIndex, optional: true},
}
for _, spec := range specs {
if len(parts[spec.index]) != spec.length && parts[spec.index] != "" {
return fmt.Errorf(
"%w: %s must be %d characters, got %d",
ErrInvalidPartLength,
spec.name,
spec.length,
len(parts[spec.index]),
)
}
}
if parts[4] == "" {
return ErrMissingExtraID
}
return nil
}
// BuildWebhookURL constructs a Teams webhook URL from components.
func BuildWebhookURL(host, group, tenant, altID, groupOwner, extraID string) string {
// Host validation moved here for clarity
if !HostValidator.MatchString(host) {
return "" // Will trigger ErrInvalidHostFormat in caller
}
return fmt.Sprintf("https://%s/%s/%s@%s/%s/%s/%s/%s",
host, Path, group, tenant, ProviderName, altID, groupOwner, extraID)
}

View file

@ -0,0 +1,89 @@
package telegram
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"
)
// apiFormat defines the Telegram API endpoint template.
const (
apiFormat = "https://api.telegram.org/bot%s/%s"
maxlength = 4096
)
// ErrMessageTooLong indicates that the message exceeds the maximum allowed length.
var (
ErrMessageTooLong = errors.New("Message exceeds the max length")
)
// Service sends notifications to configured Telegram chats.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Telegram.
func (service *Service) Send(message string, params *types.Params) error {
if len(message) > maxlength {
return ErrMessageTooLong
}
config := *service.Config
if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return fmt.Errorf("updating config from params: %w", err)
}
return service.sendMessageForChatIDs(message, &config)
}
// 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{
Preview: true,
Notification: true,
}
service.pkr = format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&service.pkr, configURL); err != nil {
return err
}
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// sendMessageForChatIDs sends the message to all configured chat IDs.
func (service *Service) sendMessageForChatIDs(message string, config *Config) error {
for _, chat := range service.Config.Chats {
if err := sendMessageToAPI(message, chat, config); err != nil {
return err
}
}
return nil
}
// GetConfig returns the current configuration for the service.
func (service *Service) GetConfig() *Config {
return service.Config
}
// sendMessageToAPI sends a message to the Telegram API for a specific chat.
func sendMessageToAPI(message string, chat string, config *Config) error {
client := &Client{token: config.Token}
payload := createSendMessagePayload(message, chat, config)
_, err := client.SendMessage(&payload)
return err
}

View file

@ -0,0 +1,74 @@
package telegram
import (
"encoding/json"
"fmt"
"github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient"
)
// Client for Telegram API.
type Client struct {
token string
}
func (c *Client) apiURL(endpoint string) string {
return fmt.Sprintf(apiFormat, c.token, endpoint)
}
// GetBotInfo returns the bot User info.
func (c *Client) GetBotInfo() (*User, error) {
response := &userResponse{}
err := jsonclient.Get(c.apiURL("getMe"), response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return &response.Result, nil
}
// GetUpdates retrieves the latest updates.
func (c *Client) GetUpdates(
offset int,
limit int,
timeout int,
allowedUpdates []string,
) ([]Update, error) {
request := &updatesRequest{
Offset: offset,
Limit: limit,
Timeout: timeout,
AllowedUpdates: allowedUpdates,
}
response := &updatesResponse{}
err := jsonclient.Post(c.apiURL("getUpdates"), request, response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return response.Result, nil
}
// SendMessage sends the specified Message.
func (c *Client) SendMessage(message *SendMessagePayload) (*Message, error) {
response := &messageResponse{}
err := jsonclient.Post(c.apiURL("sendMessage"), message, response)
if !response.OK {
return nil, GetErrorResponse(jsonclient.ErrorBody(err))
}
return response.Result, nil
}
// GetErrorResponse retrieves the error message from a failed request.
func GetErrorResponse(body string) error {
response := &responseError{}
if err := json.Unmarshal([]byte(body), response); err == nil {
return response
}
return nil
}

View file

@ -0,0 +1,94 @@
package telegram
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme identifies this service in configuration URLs.
const (
Scheme = "telegram"
)
// ErrInvalidToken indicates an invalid Telegram token format or content.
var (
ErrInvalidToken = errors.New("invalid telegram token")
ErrNoChannelsDefined = errors.New("no channels defined in config URL")
)
// Config holds settings for the Telegram notification service.
type Config struct {
Token string `url:"user"`
Preview bool ` default:"Yes" desc:"If disabled, no web page preview will be displayed for URLs" key:"preview"`
Notification bool ` default:"Yes" desc:"If disabled, sends Message silently" key:"notification"`
ParseMode parseMode ` default:"None" desc:"How the text Message should be parsed" key:"parsemode"`
Chats []string ` desc:"Chat IDs or Channel names (using @channel-name)" key:"chats,channels"`
Title string ` default:"" desc:"Notification title, optionally set by the sender" key:"title"`
}
// Enums returns the fields that use an EnumFormatter for their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{
"ParseMode": ParseModes.Enum,
}
}
// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates the configuration from a URL representation.
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 {
tokenParts := strings.Split(config.Token, ":")
return &url.URL{
User: url.UserPassword(tokenParts[0], tokenParts[1]),
Host: Scheme,
Scheme: Scheme,
ForceQuery: true,
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 {
password, _ := url.User.Password()
token := url.User.Username() + ":" + password
if url.String() != "telegram://dummy@dummy.com" {
if !IsTokenValid(token) {
return fmt.Errorf("%w: %s", ErrInvalidToken, token)
}
}
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting config property %q from URL query: %w", key, err)
}
}
if url.String() != "telegram://dummy@dummy.com" {
if len(config.Chats) < 1 {
return ErrNoChannelsDefined
}
}
config.Token = token
return nil
}

View file

@ -0,0 +1,215 @@
package telegram
import (
"errors"
"fmt"
"io"
"os"
"os/signal"
"slices"
"strconv"
"syscall"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util/generator"
)
// UpdatesLimit defines the number of updates to retrieve per API call.
const (
UpdatesLimit = 10 // Number of updates to retrieve per call
UpdatesTimeout = 120 // Timeout in seconds for long polling
)
// ErrNoChatsSelected indicates that no chats were selected during generation.
var (
ErrNoChatsSelected = errors.New("no chats were selected")
)
// Generator facilitates Telegram-specific URL generation via user interaction.
type Generator struct {
userDialog *generator.UserDialog
client *Client
chats []string
chatNames []string
chatTypes []string
done bool
botName string
Reader io.Reader
Writer io.Writer
}
// Generate creates a Telegram Shoutrrr configuration from user dialog input.
func (g *Generator) Generate(
_ types.Service,
props map[string]string,
_ []string,
) (types.ServiceConfig, error) {
var config Config
if g.Reader == nil {
g.Reader = os.Stdin
}
if g.Writer == nil {
g.Writer = os.Stdout
}
g.userDialog = generator.NewUserDialog(g.Reader, g.Writer, props)
userDialog := g.userDialog
userDialog.Writelnf(
"To start we need your bot token. If you haven't created a bot yet, you can use this link:",
)
userDialog.Writelnf(" %v", format.ColorizeLink("https://t.me/botfather?start"))
userDialog.Writelnf("")
token := userDialog.QueryString(
"Enter your bot token:",
generator.ValidateFormat(IsTokenValid),
"token",
)
userDialog.Writelnf("Fetching bot info...")
g.client = &Client{token: token}
botInfo, err := g.client.GetBotInfo()
if err != nil {
return &Config{}, err
}
g.botName = botInfo.Username
userDialog.Writelnf("")
userDialog.Writelnf(
"Okay! %v will listen for any messages in PMs and group chats it is invited to.",
format.ColorizeString("@", g.botName),
)
g.done = false
lastUpdate := 0
signals := make(chan os.Signal, 1)
// Subscribe to system signals
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
for !g.done {
userDialog.Writelnf("Waiting for messages to arrive...")
updates, err := g.client.GetUpdates(lastUpdate, UpdatesLimit, UpdatesTimeout, nil)
if err != nil {
panic(err)
}
// If no updates were retrieved, prompt user to continue
promptDone := len(updates) == 0
for _, update := range updates {
lastUpdate = update.UpdateID + 1
switch {
case update.Message != nil || update.ChannelPost != nil:
message := update.Message
if update.ChannelPost != nil {
message = update.ChannelPost
}
chat := message.Chat
source := message.Chat.Name()
if message.From != nil {
source = "@" + message.From.Username
}
userDialog.Writelnf("Got Message '%v' from %v in %v chat %v",
format.ColorizeString(message.Text),
format.ColorizeProp(source),
format.ColorizeEnum(chat.Type),
format.ColorizeNumber(chat.ID))
userDialog.Writelnf(g.addChat(chat))
// Another chat was added, prompt user to continue
promptDone = true
case update.ChatMemberUpdate != nil:
cmu := update.ChatMemberUpdate
oldStatus := cmu.OldChatMember.Status
newStatus := cmu.NewChatMember.Status
userDialog.Writelnf(
"Got a bot chat member update for %v, status was changed from %v to %v",
format.ColorizeProp(cmu.Chat.Name()),
format.ColorizeEnum(oldStatus),
format.ColorizeEnum(newStatus),
)
default:
userDialog.Writelnf("Got unknown Update. Ignored!")
}
}
if promptDone {
userDialog.Writelnf("")
g.done = !userDialog.QueryBool(
fmt.Sprintf("Got %v chat ID(s) so far. Want to add some more?",
format.ColorizeNumber(len(g.chats))),
"",
)
}
}
userDialog.Writelnf("")
userDialog.Writelnf("Cleaning up the bot session...")
// Notify API that we got the updates
if _, err = g.client.GetUpdates(lastUpdate, 0, 0, nil); err != nil {
g.userDialog.Writelnf(
"Failed to mark last updates as received: %v",
format.ColorizeError(err),
)
}
if len(g.chats) < 1 {
return nil, ErrNoChatsSelected
}
userDialog.Writelnf("Selected chats:")
for i, id := range g.chats {
name := g.chatNames[i]
chatType := g.chatTypes[i]
userDialog.Writelnf(
" %v (%v) %v",
format.ColorizeNumber(id),
format.ColorizeEnum(chatType),
format.ColorizeString(name),
)
}
userDialog.Writelnf("")
config = Config{
Notification: true,
Token: token,
Chats: g.chats,
}
return &config, nil
}
// addChat adds a chat to the generator's list if its not already present.
func (g *Generator) addChat(chat *Chat) string {
chatID := strconv.FormatInt(chat.ID, 10)
name := chat.Name()
if slices.Contains(g.chats, chatID) {
return fmt.Sprintf("chat %v is already selected!", format.ColorizeString(name))
}
g.chats = append(g.chats, chatID)
g.chatNames = append(g.chatNames, name)
g.chatTypes = append(g.chatTypes, chat.Type)
return fmt.Sprintf("Added new chat %v!", format.ColorizeString(name))
}

View file

@ -0,0 +1,131 @@
package telegram_test
import (
"fmt"
"io"
"strings"
"github.com/jarcoal/httpmock"
"github.com/mattn/go-colorable"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/nicholas-fedor/shoutrrr/pkg/services/telegram"
)
const (
mockToken = `0:MockToken`
mockAPIBase = "https://api.telegram.org/bot" + mockToken + "/"
)
var (
userOut *gbytes.Buffer
userIn *gbytes.Buffer
userInMono io.Writer
)
func mockTyped(a ...any) {
fmt.Fprint(userOut, a...)
fmt.Fprint(userOut, "\n")
}
func dumpBuffers() {
for _, line := range strings.Split(string(userIn.Contents()), "\n") {
fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n")
}
for _, line := range strings.Split(string(userOut.Contents()), "\n") {
fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n")
}
}
func mockAPI(endpoint string) string {
return mockAPIBase + endpoint
}
var _ = ginkgo.Describe("TelegramGenerator", func() {
ginkgo.BeforeEach(func() {
userOut = gbytes.NewBuffer()
userIn = gbytes.NewBuffer()
userInMono = colorable.NewNonColorable(userIn)
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should return the ", func() {
gen := telegram.Generator{
Reader: userOut,
Writer: userInMono,
}
resultChannel := make(chan string, 1)
httpmock.RegisterResponder(
"GET",
mockAPI(`getMe`),
httpmock.NewJsonResponderOrPanic(200, &struct {
OK bool
Result *telegram.User
}{
true, &telegram.User{
ID: 1,
IsBot: true,
Username: "mockbot",
},
}),
)
httpmock.RegisterResponder(
"POST",
mockAPI(`getUpdates`),
httpmock.NewJsonResponderOrPanic(200, &struct {
OK bool
Result []telegram.Update
}{
true,
[]telegram.Update{
{
ChatMemberUpdate: &telegram.ChatMemberUpdate{
Chat: &telegram.Chat{Type: `channel`, Title: `mockChannel`},
OldChatMember: &telegram.ChatMember{Status: `kicked`},
NewChatMember: &telegram.ChatMember{Status: `administrator`},
},
},
{
Message: &telegram.Message{
Text: "hi!",
From: &telegram.User{Username: `mockUser`},
Chat: &telegram.Chat{Type: `private`, ID: 667, Username: `mockUser`},
},
},
},
}),
)
go func() {
defer ginkgo.GinkgoRecover()
conf, err := gen.Generate(nil, nil, nil)
gomega.Expect(conf).ToNot(gomega.BeNil())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
resultChannel <- conf.GetURL().String()
}()
defer dumpBuffers()
mockTyped(mockToken)
mockTyped(`no`)
gomega.Eventually(userIn).
Should(gbytes.Say(`Got a bot chat member update for mockChannel, status was changed from kicked to administrator`))
gomega.Eventually(userIn).
Should(gbytes.Say(`Got 1 chat ID\(s\) so far\. Want to add some more\?`))
gomega.Eventually(userIn).Should(gbytes.Say(`Selected chats:`))
gomega.Eventually(userIn).Should(gbytes.Say(`667 \(private\) @mockUser`))
gomega.Eventually(resultChannel).
Should(gomega.Receive(gomega.Equal(`telegram://0:MockToken@telegram?chats=667&preview=No`)))
})
})

View file

@ -0,0 +1,153 @@
package telegram
import (
"encoding/json"
"errors"
"log"
"net/url"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/gstruct"
)
var _ = ginkgo.Describe("the telegram service", func() {
var logger *log.Logger
ginkgo.BeforeEach(func() {
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
})
ginkgo.Describe("creating configurations", func() {
ginkgo.When("given an url", func() {
ginkgo.When("a parse mode is not supplied", func() {
ginkgo.It("no parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("a parse mode is supplied", func() {
ginkgo.When("it's set to a valid mode and not None", func() {
ginkgo.It("parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=Markdown",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).To(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("it's set to None", func() {
ginkgo.When("no title has been provided", func() {
ginkgo.It("no parse_mode should be present in payload", func() {
payload, err := getPayloadStringFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=None",
"Message",
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode"))
})
})
ginkgo.When("a title has been provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have parse_mode set to HTML", func() {
gomega.Expect(payload.ParseMode).To(gomega.Equal("HTML"))
})
ginkgo.It("should contain the title prepended in the message", func() {
gomega.Expect(payload.Text).To(gomega.ContainSubstring("MessageTitle"))
})
ginkgo.It("should escape the message HTML tags", func() {
gomega.Expect(payload.Text).To(gomega.ContainSubstring("&lt;3"))
gomega.Expect(payload.Text).
To(gomega.ContainSubstring("Cool &amp; stuff"))
gomega.Expect(payload.Text).To(gomega.ContainSubstring("-&gt;"))
})
})
})
})
ginkgo.When("parsing URL that might have a message thread id", func() {
ginkgo.When("no thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to nil", func() {
gomega.Expect(payload.MessageThreadID).To(gomega.BeNil())
})
})
ginkgo.When("a numeric thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1:10&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to 10", func() {
gomega.Expect(payload.MessageThreadID).To(gstruct.PointTo(gomega.Equal(10)))
})
})
ginkgo.When("non-numeric thread id is provided", func() {
payload, err := getPayloadFromURL(
"telegram://12345:mock-token@telegram/?channels=channel-1:invalid&title=MessageTitle",
`Oh wow! <3 Cool & stuff ->`,
logger,
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
ginkgo.It("should have message_thread_id set to nil", func() {
gomega.Expect(payload.MessageThreadID).To(gomega.BeNil())
})
})
})
})
})
})
func getPayloadFromURL(
testURL string,
message string,
logger *log.Logger,
) (SendMessagePayload, error) {
telegram := &Service{}
serviceURL, err := url.Parse(testURL)
if err != nil {
return SendMessagePayload{}, err
}
if err = telegram.Initialize(serviceURL, logger); err != nil {
return SendMessagePayload{}, err
}
if len(telegram.Config.Chats) < 1 {
return SendMessagePayload{}, errors.New("no channels were supplied")
}
return createSendMessagePayload(message, telegram.Config.Chats[0], telegram.Config), nil
}
func getPayloadStringFromURL(testURL string, message string, logger *log.Logger) ([]byte, error) {
payload, err := getPayloadFromURL(testURL, message, logger)
if err != nil {
return nil, err
}
raw, err := json.Marshal(payload)
return raw, err
}

View file

@ -0,0 +1,234 @@
package telegram
import (
"fmt"
"html"
"strconv"
"strings"
)
// SendMessagePayload is the notification payload for the telegram notification service.
type SendMessagePayload struct {
Text string `json:"text"`
ID string `json:"chat_id"`
MessageThreadID *int `json:"message_thread_id,omitempty"`
ParseMode string `json:"parse_mode,omitempty"`
DisablePreview bool `json:"disable_web_page_preview"`
DisableNotification bool `json:"disable_notification"`
ReplyMarkup *replyMarkup `json:"reply_markup,omitempty"`
Entities []entity `json:"entities,omitempty"`
ReplyTo int64 `json:"reply_to_message_id"`
MessageID int64 `json:"message_id,omitempty"`
}
// Message represents one chat message.
type Message struct {
MessageID int64 `json:"message_id"`
Text string `json:"text"`
From *User `json:"from"`
Chat *Chat `json:"chat"`
}
type messageResponse struct {
OK bool `json:"ok"`
Result *Message `json:"result"`
}
type responseError struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
type userResponse struct {
OK bool `json:"ok"`
Result User `json:"result"`
}
// User contains information about a telegram user or bot.
type User struct {
// Unique identifier for this User or bot
ID int64 `json:"id"`
// True, if this User is a bot
IsBot bool `json:"is_bot"`
// User's or bot's first name
FirstName string `json:"first_name"`
// Optional. User's or bot's last name
LastName string `json:"last_name"`
// Optional. User's or bot's username
Username string `json:"username"`
// Optional. IETF language tag of the User's language
LanguageCode string `json:"language_code"`
// Optional. True, if the bot can be invited to groups. Returned only in getMe.
CanJoinGroups bool `json:"can_join_groups"`
// Optional. True, if privacy mode is disabled for the bot. Returned only in getMe.
CanReadAllGroupMessages bool `json:"can_read_all_group_messages"`
// Optional. True, if the bot supports inline queries. Returned only in getMe.
SupportsInlineQueries bool `json:"supports_inline_queries"`
}
type updatesRequest struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
Timeout int `json:"timeout"`
AllowedUpdates []string `json:"allowed_updates"`
}
type updatesResponse struct {
OK bool `json:"ok"`
Result []Update `json:"result"`
}
type inlineQuery struct {
// Unique identifier for this query
ID string `json:"id"`
// Sender
From User `json:"from"`
// Text of the query (up to 256 characters)
Query string `json:"query"`
// Offset of the results to be returned, can be controlled by the bot
Offset string `json:"offset"`
}
type chosenInlineResult struct{}
// Update contains state changes since the previous Update.
type Update struct {
// The Update's unique identifier. Update identifiers start from a certain positive number and increase sequentially. This ID becomes especially handy if you're using Webhooks, since it allows you to ignore repeated updates or to restore the correct Update sequence, should they get out of order. If there are no new updates for at least a week, then identifier of the next Update will be chosen randomly instead of sequentially.
UpdateID int `json:"update_id"`
// Optional. New incoming Message of any kind — text, photo, sticker, etc.
Message *Message `json:"Message"`
// Optional. New version of a Message that is known to the bot and was edited
EditedMessage *Message `json:"edited_message"`
// Optional. New incoming channel post of any kind — text, photo, sticker, etc.
ChannelPost *Message `json:"channel_post"`
// Optional. New version of a channel post that is known to the bot and was edited
EditedChannelPost *Message `json:"edited_channel_post"`
// Optional. New incoming inline query
InlineQuery *inlineQuery `json:"inline_query"`
//// Optional. The result of an inline query that was chosen by a User and sent to their chat partner. Please see our documentation on the feedback collecting for details on how to enable these updates for your bot.
ChosenInlineResult *chosenInlineResult `json:"chosen_inline_result"`
//// Optional. New incoming callback query
CallbackQuery *callbackQuery `json:"callback_query"`
// API fields that are not used by the client has been commented out
//// Optional. New incoming shipping query. Only for invoices with flexible price
// ShippingQuery `json:"shipping_query"`
//// Optional. New incoming pre-checkout query. Contains full information about checkout
// PreCheckoutQuery `json:"pre_checkout_query"`
//// Optional. New poll state. Bots receive only updates about stopped polls and polls, which are sent by the bot
// Poll `json:"poll"`
//// Optional. A User changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself.
// Poll_answer PollAnswer `json:"poll_answer"`
ChatMemberUpdate *ChatMemberUpdate `json:"my_chat_member"`
}
// Chat represents a telegram conversation.
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username string `json:"username"`
}
type inlineKey struct {
Text string `json:"text"`
URL string `json:"url"`
LoginURL string `json:"login_url"`
CallbackData string `json:"callback_data"`
SwitchInlineQuery string `json:"switch_inline_query"`
SwitchInlineQueryCurrent string `json:"switch_inline_query_current_chat"`
}
type replyMarkup struct {
InlineKeyboard [][]inlineKey `json:"inline_keyboard,omitempty"`
}
type entity struct {
Type string `json:"type"`
Offset int `json:"offset"`
Length int `json:"length"`
}
type callbackQuery struct {
ID string `json:"id"`
From *User `json:"from"`
Message *Message `json:"Message"`
Data string `json:"data"`
}
// ChatMemberUpdate represents a member update in a telegram chat.
type ChatMemberUpdate struct {
// Chat the user belongs to
Chat *Chat `json:"chat"`
// Performer of the action, which resulted in the change
From *User `json:"from"`
// Date the change was done in Unix time
Date int `json:"date"`
// Previous information about the chat member
OldChatMember *ChatMember `json:"old_chat_member"`
// New information about the chat member
NewChatMember *ChatMember `json:"new_chat_member"`
// Optional. Chat invite link, which was used by the user to join the chat; for joining by invite link events only.
// invite_link ChatInviteLink
}
// ChatMember represents the membership state for a user in a telegram chat.
type ChatMember struct {
// The member's status in the chat
Status string `json:"status"`
// Information about the user
User *User `json:"user"`
}
func (e *responseError) Error() string {
return e.Description
}
// Name returns the name of the channel based on its type.
func (c *Chat) Name() string {
if c.Type == "private" || c.Type == "channel" && c.Username != "" {
return "@" + c.Username
}
return c.Title
}
func createSendMessagePayload(message string, channel string, config *Config) SendMessagePayload {
var threadID *int
chatID, thread, ok := strings.Cut(channel, ":")
if ok {
if parsed, err := strconv.Atoi(thread); err == nil {
threadID = &parsed
}
}
payload := SendMessagePayload{
Text: message,
ID: chatID,
MessageThreadID: threadID,
DisableNotification: !config.Notification,
DisablePreview: !config.Preview,
}
parseMode := config.ParseMode
if config.ParseMode == ParseModes.None && config.Title != "" {
parseMode = ParseModes.HTML
// no parse mode has been provided, treat message as unescaped HTML
message = html.EscapeString(message)
}
if parseMode != ParseModes.None {
payload.ParseMode = parseMode.String()
}
// only HTML parse mode is supported for titles
if parseMode == ParseModes.HTML {
payload.Text = fmt.Sprintf("<b>%v</b>\n%v", html.EscapeString(config.Title), message)
}
return payload
}

Some files were not shown because too many files have changed in this diff Show more