1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View file

@ -0,0 +1,49 @@
package format
import (
"errors"
"fmt"
"reflect"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var ErrNotConfigProp = errors.New("struct field cannot be used as a prop")
// GetConfigPropFromString deserializes a config property from a string representation using the ConfigProp interface.
func GetConfigPropFromString(structType reflect.Type, value string) (reflect.Value, error) {
valuePtr := reflect.New(structType)
configProp, ok := valuePtr.Interface().(types.ConfigProp)
if !ok {
return reflect.Value{}, ErrNotConfigProp
}
if err := configProp.SetFromProp(value); err != nil {
return reflect.Value{}, fmt.Errorf("failed to set config prop from string: %w", err)
}
return valuePtr, nil
}
// GetConfigPropString serializes a config property to a string representation using the ConfigProp interface.
func GetConfigPropString(propPtr reflect.Value) (string, error) {
if propPtr.Kind() != reflect.Ptr {
propVal := propPtr
propPtr = reflect.New(propVal.Type())
propPtr.Elem().Set(propVal)
}
if propPtr.CanInterface() {
if configProp, ok := propPtr.Interface().(types.ConfigProp); ok {
s, err := configProp.GetPropValue()
if err != nil {
return "", fmt.Errorf("failed to get config prop string: %w", err)
}
return s, nil
}
}
return "", ErrNotConfigProp
}

View file

@ -0,0 +1,71 @@
package format
import (
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// EnumInvalid is the constant value that an enum gets assigned when it could not be parsed.
const EnumInvalid = -1
// EnumFormatter is the helper methods for enum-like types.
type EnumFormatter struct {
names []string
firstOffset int
aliases map[string]int
}
// Names is the list of the valid Enum string values.
func (ef EnumFormatter) Names() []string {
return ef.names[ef.firstOffset:]
}
// Print takes a enum mapped int and returns it's string representation or "Invalid".
func (ef EnumFormatter) Print(e int) string {
if e >= len(ef.names) || e < 0 {
return "Invalid"
}
return ef.names[e]
}
// Parse takes an enum mapped string and returns it's int representation or EnumInvalid (-1).
func (ef EnumFormatter) Parse(mappedString string) int {
target := strings.ToLower(mappedString)
for index, name := range ef.names {
if target == strings.ToLower(name) {
return index
}
}
if index, found := ef.aliases[mappedString]; found {
return index
}
return EnumInvalid
}
// CreateEnumFormatter creates a EnumFormatter struct.
func CreateEnumFormatter(names []string, optAliases ...map[string]int) types.EnumFormatter {
aliases := map[string]int{}
if len(optAliases) > 0 {
aliases = optAliases[0]
}
firstOffset := 0
for i, name := range names {
if name != "" {
firstOffset = i
break
}
}
return &EnumFormatter{
names,
firstOffset,
aliases,
}
}

134
pkg/format/field_info.go Normal file
View file

@ -0,0 +1,134 @@
package format
import (
"reflect"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// DefaultBase represents the default numeric base (decimal) for fields.
const DefaultBase = 10
// FieldInfo is the meta data about a config field.
type FieldInfo struct {
Name string
Type reflect.Type
EnumFormatter types.EnumFormatter
Description string
DefaultValue string
Template string
Required bool
URLParts []URLPart
Title bool
Base int
Keys []string
ItemSeparator rune
}
// IsEnum returns whether a EnumFormatter has been assigned to the field and that it is of a suitable type.
func (fi *FieldInfo) IsEnum() bool {
return fi.EnumFormatter != nil && fi.Type.Kind() == reflect.Int
}
// IsURLPart returns whether the field is serialized as the specified part of an URL.
func (fi *FieldInfo) IsURLPart(part URLPart) bool {
for _, up := range fi.URLParts {
if up == part {
return true
}
}
return false
}
func getStructFieldInfo(structType reflect.Type, enums map[string]types.EnumFormatter) []FieldInfo {
numFields := structType.NumField()
fields := make([]FieldInfo, 0, numFields)
maxKeyLen := 0
for i := range numFields {
fieldDef := structType.Field(i)
if isHiddenField(fieldDef) {
// This is an embedded or private field, which should not be part of the Config output
continue
}
info := FieldInfo{
Name: fieldDef.Name,
Type: fieldDef.Type,
Required: true,
Title: false,
ItemSeparator: ',',
}
if util.IsNumeric(fieldDef.Type.Kind()) {
info.Base = getFieldBase(fieldDef)
}
if tag, ok := fieldDef.Tag.Lookup("desc"); ok {
info.Description = tag
}
if tag, ok := fieldDef.Tag.Lookup("tpl"); ok {
info.Template = tag
}
if tag, ok := fieldDef.Tag.Lookup("default"); ok {
info.Required = false
info.DefaultValue = tag
}
if _, ok := fieldDef.Tag.Lookup("optional"); ok {
info.Required = false
}
if _, ok := fieldDef.Tag.Lookup("title"); ok {
info.Title = true
}
if tag, ok := fieldDef.Tag.Lookup("url"); ok {
info.URLParts = ParseURLParts(tag)
}
if tag, ok := fieldDef.Tag.Lookup("key"); ok {
tag := strings.ToLower(tag)
info.Keys = strings.Split(tag, ",")
}
if tag, ok := fieldDef.Tag.Lookup("sep"); ok {
info.ItemSeparator = rune(tag[0])
}
if ef, isEnum := enums[fieldDef.Name]; isEnum {
info.EnumFormatter = ef
}
fields = append(fields, info)
keyLen := len(fieldDef.Name)
if keyLen > maxKeyLen {
maxKeyLen = keyLen
}
}
return fields
}
func isHiddenField(field reflect.StructField) bool {
return field.Anonymous || strings.ToUpper(field.Name[0:1]) != field.Name[0:1]
}
func getFieldBase(field reflect.StructField) int {
if tag, ok := field.Tag.Lookup("base"); ok {
if base, err := strconv.ParseUint(tag, 10, 8); err == nil {
return int(base)
}
}
// Default to base 10 if not tagged
return DefaultBase
}

34
pkg/format/format.go Normal file
View file

@ -0,0 +1,34 @@
package format
import (
"strconv"
"strings"
)
// ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value.
func ParseBool(value string, defaultValue bool) (bool, bool) {
switch strings.ToLower(value) {
case "true", "1", "yes", "y":
return true, true
case "false", "0", "no", "n":
return false, true
default:
return defaultValue, false
}
}
// PrintBool returns "Yes" if value is true, otherwise returns "No".
func PrintBool(value bool) string {
if value {
return "Yes"
}
return "No"
}
// IsNumber returns whether the specified string is number-like.
func IsNumber(value string) bool {
_, err := strconv.ParseFloat(value, 64)
return err == nil
}

View file

@ -0,0 +1,80 @@
package format
import "github.com/fatih/color"
// ColorizeDesc colorizes the input string as "Description".
var ColorizeDesc = color.New(color.FgHiBlack).SprintFunc()
// ColorizeTrue colorizes the input string as "True".
var ColorizeTrue = color.New(color.FgHiGreen).SprintFunc()
// ColorizeFalse colorizes the input string as "False".
var ColorizeFalse = color.New(color.FgHiRed).SprintFunc()
// ColorizeNumber colorizes the input string as "Number".
var ColorizeNumber = color.New(color.FgHiBlue).SprintFunc()
// ColorizeString colorizes the input string as "String".
var ColorizeString = color.New(color.FgHiYellow).SprintFunc()
// ColorizeEnum colorizes the input string as "Enum".
var ColorizeEnum = color.New(color.FgHiCyan).SprintFunc()
// ColorizeProp colorizes the input string as "Prop".
var ColorizeProp = color.New(color.FgHiMagenta).SprintFunc()
// ColorizeError colorizes the input string as "Error".
var ColorizeError = ColorizeFalse
// ColorizeContainer colorizes the input string as "Container".
var ColorizeContainer = ColorizeDesc
// ColorizeLink colorizes the input string as "Link".
var ColorizeLink = color.New(color.FgHiBlue).SprintFunc()
// ColorizeValue colorizes the input string according to what type appears to be.
func ColorizeValue(value string, isEnum bool) string {
if isEnum {
return ColorizeEnum(value)
}
if isTrue, isType := ParseBool(value, false); isType {
if isTrue {
return ColorizeTrue(value)
}
return ColorizeFalse(value)
}
if IsNumber(value) {
return ColorizeNumber(value)
}
return ColorizeString(value)
}
// ColorizeToken colorizes the value according to the tokenType.
func ColorizeToken(value string, tokenType NodeTokenType) string {
switch tokenType {
case NumberToken:
return ColorizeNumber(value)
case EnumToken:
return ColorizeEnum(value)
case TrueToken:
return ColorizeTrue(value)
case FalseToken:
return ColorizeFalse(value)
case PropToken:
return ColorizeProp(value)
case ErrorToken:
return ColorizeError(value)
case ContainerToken:
return ColorizeContainer(value)
case StringToken:
return ColorizeString(value)
case UnknownToken:
default:
}
return value
}

View file

@ -0,0 +1,95 @@
package format
import (
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// BuildQuery converts the fields of a config object to a delimited query string.
func BuildQuery(cqr types.ConfigQueryResolver) string {
return BuildQueryWithCustomFields(cqr, url.Values{}).Encode()
}
// escaping any custom fields that share the same key as a config prop using a "__" prefix.
func BuildQueryWithCustomFields(cqr types.ConfigQueryResolver, query url.Values) url.Values {
fields := cqr.QueryFields()
skipEscape := len(query) < 1
pkr, isPkr := cqr.(*PropKeyResolver)
for _, key := range fields {
if !skipEscape {
// Escape any webhook query keys using the same name as service props
escValues := query[key]
if len(escValues) > 0 {
query.Del(key)
query[EscapeKey(key)] = escValues
}
}
if isPkr && !pkr.KeyIsPrimary(key) {
continue
}
value, err := cqr.Get(key)
if err != nil || isPkr && pkr.IsDefault(key, value) {
continue
}
query.Set(key, value)
}
return query
}
// SetConfigPropsFromQuery iterates over all the config prop keys and sets the config prop to the corresponding
// query value based on the key.
// SetConfigPropsFromQuery returns a non-nil url.Values query with all config prop keys removed, even if any of
// them could not be used to set a config field, and with any escaped keys unescaped.
// The error returned is the first error that occurred, subsequent errors are just discarded.
func SetConfigPropsFromQuery(cqr types.ConfigQueryResolver, query url.Values) (url.Values, error) {
var firstError error
if query == nil {
return url.Values{}, nil
}
for _, key := range cqr.QueryFields() {
// Retrieve the service-related prop value
values := query[key]
if len(values) > 0 {
if err := cqr.Set(key, values[0]); err != nil && firstError == nil {
firstError = err
}
}
// Remove it from the query Values
query.Del(key)
// If an escaped version of the key exist, unescape it
escKey := EscapeKey(key)
escValues := query[escKey]
if len(escValues) > 0 {
query.Del(escKey)
query[key] = escValues
}
}
return query, firstError
}
// EscapeKey adds the KeyPrefix to custom URL query keys that conflict with service config prop keys.
func EscapeKey(key string) string {
return KeyPrefix + key
}
// UnescapeKey removes the KeyPrefix from custom URL query keys that conflict with service config prop keys.
func UnescapeKey(key string) string {
return strings.TrimPrefix(key, KeyPrefix)
}
// consisting of two underscore characters ("__").
const KeyPrefix = "__"

View file

@ -0,0 +1,50 @@
package format
import (
"net/url"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = ginkgo.Describe("Query Formatter", func() {
var pkr PropKeyResolver
ginkgo.BeforeEach(func() {
ts = &testStruct{}
pkr = NewPropKeyResolver(ts)
_ = pkr.SetDefaultProps(ts)
})
ginkgo.Describe("Creating a service URL query from a config", func() {
ginkgo.When("a config property has been changed from default", func() {
ginkgo.It("should be included in the query string", func() {
ts.Str = "test"
query := BuildQuery(&pkr)
// (pkr, )
gomega.Expect(query).To(gomega.Equal("str=test"))
})
})
ginkgo.When("a custom query key conflicts with a config property key", func() {
ginkgo.It("should include both values, with the custom escaped", func() {
ts.Str = "service"
customQuery := url.Values{"str": {"custom"}}
query := BuildQueryWithCustomFields(&pkr, customQuery)
gomega.Expect(query.Encode()).To(gomega.Equal("__str=custom&str=service"))
})
})
})
ginkgo.Describe("Setting prop values from query", func() {
ginkgo.When("a custom query key conflicts with a config property key", func() {
ginkgo.It(
"should set the config prop from the regular and return the custom one unescaped",
func() {
ts.Str = "service"
serviceQuery := url.Values{"__str": {"custom"}, "str": {"service"}}
query, err := SetConfigPropsFromQuery(&pkr, serviceQuery)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(ts.Str).To(gomega.Equal("service"))
gomega.Expect(query.Get("str")).To(gomega.Equal("custom"))
},
)
})
})
})

157
pkg/format/format_test.go Normal file
View file

@ -0,0 +1,157 @@
package format
import (
"errors"
"net/url"
"testing"
"github.com/fatih/color"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestFormat(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Format Suite")
}
var _ = ginkgo.BeforeSuite(func() {
// Disable color output for tests to have them match the string format rather than the colors
color.NoColor = true
})
var _ = ginkgo.Describe("the format package", func() {
ginkgo.Describe("Generic Format Utils", func() {
ginkgo.When("parsing a bool", func() {
testParseValidBool := func(raw string, expected bool) {
parsed, ok := ParseBool(raw, !expected)
gomega.Expect(parsed).To(gomega.Equal(expected))
gomega.Expect(ok).To(gomega.BeTrue())
}
ginkgo.It("should parse truthy values as true", func() {
testParseValidBool("true", true)
testParseValidBool("1", true)
testParseValidBool("yes", true)
})
ginkgo.It("should parse falsy values as false", func() {
testParseValidBool("false", false)
testParseValidBool("0", false)
testParseValidBool("no", false)
})
ginkgo.It("should match regardless of case", func() {
testParseValidBool("trUE", true)
})
ginkgo.It("should return the default if no value matches", func() {
parsed, ok := ParseBool("bad", true)
gomega.Expect(parsed).To(gomega.BeTrue())
gomega.Expect(ok).To(gomega.BeFalse())
parsed, ok = ParseBool("values", false)
gomega.Expect(parsed).To(gomega.BeFalse())
gomega.Expect(ok).To(gomega.BeFalse())
})
})
ginkgo.When("printing a bool", func() {
ginkgo.It("should return yes or no", func() {
gomega.Expect(PrintBool(true)).To(gomega.Equal("Yes"))
gomega.Expect(PrintBool(false)).To(gomega.Equal("No"))
})
})
ginkgo.When("checking for number-like strings", func() {
ginkgo.It("should be true for numbers", func() {
gomega.Expect(IsNumber("1.5")).To(gomega.BeTrue())
gomega.Expect(IsNumber("0")).To(gomega.BeTrue())
gomega.Expect(IsNumber("NaN")).To(gomega.BeTrue())
})
ginkgo.It("should be false for non-numbers", func() {
gomega.Expect(IsNumber("baNaNa")).To(gomega.BeFalse())
})
})
})
ginkgo.Describe("Enum Formatter", func() {
ginkgo.It("should return all enum values on listing", func() {
gomega.Expect(testEnum.Names()).To(gomega.ConsistOf("None", "Foo", "Bar"))
})
})
})
type testStruct struct {
Signed int `default:"0" key:"signed"`
Unsigned uint
Str string `default:"notempty" key:"str"`
StrSlice []string
StrArray [3]string
Sub subStruct
TestEnum int `default:"None" key:"testenum"`
SubProp subPropStruct
SubSlice []subStruct
SubPropSlice []subPropStruct
SubPropPtrSlice []*subPropStruct
StrMap map[string]string
IntMap map[string]int
Int8Map map[string]int8
Int16Map map[string]int16
Int32Map map[string]int32
Int64Map map[string]int64
UintMap map[string]uint
Uint8Map map[string]int8
Uint16Map map[string]int16
Uint32Map map[string]int32
Uint64Map map[string]int64
}
func (t *testStruct) GetURL() *url.URL {
panic("not implemented")
}
func (t *testStruct) SetURL(_ *url.URL) error {
panic("not implemented")
}
func (t *testStruct) Enums() map[string]types.EnumFormatter {
return enums
}
type subStruct struct {
Value string
}
type subPropStruct struct {
Value string
}
func (s *subPropStruct) SetFromProp(propValue string) error {
if len(propValue) < 1 || propValue[0] != '@' {
return errors.New("invalid value")
}
s.Value = propValue[1:]
return nil
}
func (s *subPropStruct) GetPropValue() (string, error) {
return "@" + s.Value, nil
}
var (
testEnum = CreateEnumFormatter([]string{"None", "Foo", "Bar"})
enums = map[string]types.EnumFormatter{
"TestEnum": testEnum,
}
)
type testStructBadDefault struct {
standard.EnumlessConfig
Value int `default:"NaN" key:"value"`
}
func (t *testStructBadDefault) GetURL() *url.URL {
panic("not implemented")
}
func (t *testStructBadDefault) SetURL(_ *url.URL) error {
panic("not implemented")
}

509
pkg/format/formatter.go Normal file
View file

@ -0,0 +1,509 @@
package format
import (
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Constants for map parsing and type sizes.
const (
KeyValuePairSize = 2 // Number of elements in a key:value pair
Int32BitSize = 32 // Bit size for 32-bit integers
Int64BitSize = 64 // Bit size for 64-bit integers
)
// Errors defined as static variables for better error handling.
var (
ErrInvalidEnumValue = errors.New("not a valid enum value")
ErrInvalidBoolValue = errors.New("accepted values are 1, true, yes or 0, false, no")
ErrUnsupportedFieldKey = errors.New("field key format is not supported")
ErrInvalidFieldValue = errors.New("invalid field value format")
ErrUnsupportedField = errors.New("field format is not supported")
ErrInvalidFieldCount = errors.New("invalid field value count")
ErrInvalidFieldKind = errors.New("invalid field kind")
ErrUnsupportedMapValue = errors.New("map value format is not supported")
ErrInvalidFieldValueData = errors.New("invalid field value")
ErrFailedToSetEnumValue = errors.New("failed to set enum value")
ErrUnexpectedUintKind = errors.New("unexpected uint kind")
ErrUnexpectedIntKind = errors.New("unexpected int kind")
ErrParseIntFailed = errors.New("failed to parse integer")
ErrParseUintFailed = errors.New("failed to parse unsigned integer")
)
// GetServiceConfig extracts the inner config from a service.
func GetServiceConfig(service types.Service) types.ServiceConfig {
serviceValue := reflect.Indirect(reflect.ValueOf(service))
configField, ok := serviceValue.Type().FieldByName("Config")
if !ok {
panic("service does not have a Config field")
}
configRef := serviceValue.FieldByIndex(configField.Index)
if configRef.IsNil() {
configType := configField.Type
if configType.Kind() == reflect.Ptr {
configType = configType.Elem()
}
newConfig := reflect.New(configType).Interface()
if config, ok := newConfig.(types.ServiceConfig); ok {
return config
}
panic("failed to create new config instance")
}
if config, ok := configRef.Interface().(types.ServiceConfig); ok {
return config
}
panic("config reference is not a ServiceConfig")
}
// ColorFormatTree generates a color-highlighted string representation of a node tree.
func ColorFormatTree(rootNode *ContainerNode, withValues bool) string {
return ConsoleTreeRenderer{WithValues: withValues}.RenderTree(rootNode, "")
}
// GetServiceConfigFormat retrieves type and field information from a service's config.
func GetServiceConfigFormat(service types.Service) *ContainerNode {
return GetConfigFormat(GetServiceConfig(service))
}
// GetConfigFormat retrieves type and field information from a ServiceConfig.
func GetConfigFormat(config types.ServiceConfig) *ContainerNode {
return getRootNode(config)
}
// SetConfigField updates a config field with a deserialized value from a string.
func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (bool, error) {
configField := config.FieldByName(field.Name)
if field.EnumFormatter != nil {
return setEnumField(configField, field, inputValue)
}
switch field.Type.Kind() {
case reflect.String:
configField.SetString(inputValue)
return true, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return setIntField(configField, field, inputValue)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return setUintField(configField, field, inputValue)
case reflect.Bool:
return setBoolField(configField, inputValue)
case reflect.Map:
return setMapField(configField, field, inputValue)
case reflect.Struct:
return setStructField(configField, field, inputValue)
case reflect.Slice, reflect.Array:
return setSliceOrArrayField(configField, field, inputValue)
case reflect.Invalid,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.UnsafePointer:
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
default:
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
}
}
// setIntField handles integer field setting.
func setIntField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
number, base := util.StripNumberPrefix(inputValue)
value, err := strconv.ParseInt(number, base, field.Type.Bits())
if err != nil {
return false, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
}
configField.SetInt(value)
return true, nil
}
// setUintField handles unsigned integer field setting.
func setUintField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
number, base := util.StripNumberPrefix(inputValue)
value, err := strconv.ParseUint(number, base, field.Type.Bits())
if err != nil {
return false, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
}
configField.SetUint(value)
return true, nil
}
// setBoolField handles boolean field setting.
func setBoolField(configField reflect.Value, inputValue string) (bool, error) {
value, ok := ParseBool(inputValue, false)
if !ok {
return false, ErrInvalidBoolValue
}
configField.SetBool(value)
return true, nil
}
// setMapField handles map field setting.
func setMapField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
if field.Type.Key().Kind() != reflect.String {
return false, ErrUnsupportedFieldKey
}
mapValue := reflect.MakeMap(field.Type)
pairs := strings.Split(inputValue, ",")
for _, pair := range pairs {
elems := strings.Split(pair, ":")
if len(elems) != KeyValuePairSize {
return false, ErrInvalidFieldValue
}
key, valueRaw := elems[0], elems[1]
value, err := getMapValue(field.Type.Elem(), valueRaw)
if err != nil {
return false, err
}
mapValue.SetMapIndex(reflect.ValueOf(key), value)
}
configField.Set(mapValue)
return true, nil
}
// setStructField handles struct field setting.
func setStructField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
valuePtr, err := GetConfigPropFromString(field.Type, inputValue)
if err != nil {
return false, err
}
configField.Set(valuePtr.Elem())
return true, nil
}
// setSliceOrArrayField handles slice or array field setting.
func setSliceOrArrayField(
configField reflect.Value,
field FieldInfo,
inputValue string,
) (bool, error) {
elemType := field.Type.Elem()
elemKind := elemType.Kind()
if elemKind == reflect.Ptr {
elemKind = elemType.Elem().Kind()
}
if elemKind != reflect.Struct && elemKind != reflect.String {
return false, ErrUnsupportedField
}
values := strings.Split(inputValue, string(field.ItemSeparator))
if field.Type.Kind() == reflect.Array && len(values) != field.Type.Len() {
return false, fmt.Errorf("%w: needs to be %d", ErrInvalidFieldCount, field.Type.Len())
}
return setSliceOrArrayValues(configField, field, elemType, values)
}
// setSliceOrArrayValues sets the actual values for slice or array fields.
func setSliceOrArrayValues(
configField reflect.Value,
field FieldInfo,
elemType reflect.Type,
values []string,
) (bool, error) {
isPtrSlice := elemType.Kind() == reflect.Ptr
baseType := elemType
if isPtrSlice {
baseType = elemType.Elem()
}
if baseType.Kind() == reflect.Struct {
slice := reflect.MakeSlice(reflect.SliceOf(elemType), 0, len(values))
for _, v := range values {
propPtr, err := GetConfigPropFromString(baseType, v)
if err != nil {
return false, err
}
if isPtrSlice {
slice = reflect.Append(slice, propPtr)
} else {
slice = reflect.Append(slice, propPtr.Elem())
}
}
configField.Set(slice)
return true, nil
}
// Handle string slice/array
value := reflect.ValueOf(values)
if field.Type.Kind() == reflect.Array {
arr := reflect.Indirect(reflect.New(field.Type))
reflect.Copy(arr, value)
configField.Set(arr)
} else {
configField.Set(value)
}
return true, nil
}
// setEnumField handles enum field setting.
func setEnumField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
value := field.EnumFormatter.Parse(inputValue)
if value == EnumInvalid {
return false, fmt.Errorf(
"%w: accepted values are %v",
ErrInvalidEnumValue,
field.EnumFormatter.Names(),
)
}
configField.SetInt(int64(value))
if actual := int(configField.Int()); actual != value {
return false, fmt.Errorf(
"%w: expected %d, got %d (canSet: %v)",
ErrFailedToSetEnumValue,
value,
actual,
configField.CanSet(),
)
}
return true, nil
}
// getMapValue converts a raw string to a map value based on type.
func getMapValue(valueType reflect.Type, valueRaw string) (reflect.Value, error) {
switch valueType.Kind() {
case reflect.String:
return reflect.ValueOf(valueRaw), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return getMapUintValue(valueRaw, valueType)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return getMapIntValue(valueRaw, valueType)
case reflect.Invalid,
reflect.Bool,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnsupportedMapValue
default:
return reflect.Value{}, ErrUnsupportedMapValue
}
}
// getMapUintValue converts a string to an unsigned integer map value.
func getMapUintValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
number, base := util.StripNumberPrefix(valueRaw)
numValue, err := strconv.ParseUint(number, base, valueType.Bits())
if err != nil {
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
}
switch valueType.Kind() {
case reflect.Uint:
return reflect.ValueOf(uint(numValue)), nil
case reflect.Uint8:
if numValue > math.MaxUint8 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint8 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint8(numValue)), nil
case reflect.Uint16:
if numValue > math.MaxUint16 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint16 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint16(numValue)), nil
case reflect.Uint32:
if numValue > math.MaxUint32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint32 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint32(numValue)), nil
case reflect.Uint64:
return reflect.ValueOf(numValue), nil
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnexpectedUintKind
default:
return reflect.Value{}, ErrUnexpectedUintKind
}
}
// getMapIntValue converts a string to a signed integer map value.
func getMapIntValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
number, base := util.StripNumberPrefix(valueRaw)
numValue, err := strconv.ParseInt(number, base, valueType.Bits())
if err != nil {
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
}
switch valueType.Kind() {
case reflect.Int:
bits := valueType.Bits()
if bits == Int32BitSize {
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int%d range",
ErrParseIntFailed,
numValue,
bits,
)
}
}
return reflect.ValueOf(int(numValue)), nil
case reflect.Int8:
if numValue < math.MinInt8 || numValue > math.MaxInt8 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int8 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int8(numValue)), nil
case reflect.Int16:
if numValue < math.MinInt16 || numValue > math.MaxInt16 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int16 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int16(numValue)), nil
case reflect.Int32:
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int32 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int32(numValue)), nil
case reflect.Int64:
return reflect.ValueOf(numValue), nil
case reflect.Invalid,
reflect.Bool,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnexpectedIntKind
default:
return reflect.Value{}, ErrUnexpectedIntKind
}
}
// GetConfigFieldString converts a config field value to its string representation.
func GetConfigFieldString(config reflect.Value, field FieldInfo) (string, error) {
configField := config.FieldByName(field.Name)
if field.IsEnum() {
return field.EnumFormatter.Print(int(configField.Int())), nil
}
strVal, token := getValueNodeValue(configField, &field)
if token == ErrorToken {
return "", ErrInvalidFieldValueData
}
return strVal, nil
}

View file

@ -0,0 +1,288 @@
package format
import (
"reflect"
"strings"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
// logger *log.Logger.
var (
ts *testStruct
tv reflect.Value
nodeMap map[string]Node
)
var _ = ginkgo.Describe("SetConfigField", func() {
testConfig := testStruct{}
tt := reflect.TypeOf(testConfig)
ginkgo.When("updating a struct", func() {
ginkgo.BeforeEach(func() {
tsPtr := reflect.New(tt)
tv = tsPtr.Elem()
ts = tsPtr.Interface().(*testStruct)
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
rootNode := getRootNode(ts)
nodeMap = make(map[string]Node, len(rootNode.Items))
for _, item := range rootNode.Items {
field := item.Field()
nodeMap[field.Name] = item
}
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
To(gomega.Equal(0), "TestEnum initial value")
})
ginkgo.When("setting an integer value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "3")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.Signed).To(gomega.Equal(3))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
ts.Signed = 2
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "z7")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
gomega.Expect(ts.Signed).To(gomega.Equal(2))
})
})
})
ginkgo.When("setting an unsigned integer value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "6")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(6)))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
ts.Unsigned = 2
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "-3")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(2)))
})
})
})
ginkgo.When("setting a string slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrSlice"].Field(),
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.StrSlice).To(gomega.HaveLen(2))
})
})
})
ginkgo.When("setting a string array value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrArray"].Field(),
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah,meawannothinkalitabitsmartah",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
})
})
ginkgo.When("the value has too many elements", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrArray"].Field(),
"one,two,three,four?",
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
})
})
ginkgo.When("the value has too few elements", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(tv, *nodeMap["StrArray"].Field(), "oneassis,two")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
})
})
})
ginkgo.When("setting a struct value", func() {
ginkgo.When("it doesn't implement ConfigProp", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(tv, *nodeMap["Sub"].Field(), "@awol")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).NotTo(gomega.BeTrue())
})
})
ginkgo.When("it implements ConfigProp", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["SubProp"].Field(), "@awol")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubProp.Value).To(gomega.Equal("awol"))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubProp"].Field(),
"missing initial at symbol",
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).NotTo(gomega.BeTrue())
})
})
})
})
ginkgo.When("setting a struct slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubPropSlice"].Field(),
"@alice,@merton",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubPropSlice).To(gomega.HaveLen(2))
})
})
})
ginkgo.When("setting a struct pointer slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubPropPtrSlice"].Field(),
"@the,@best",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubPropPtrSlice).To(gomega.HaveLen(2))
})
})
})
})
ginkgo.When("formatting stuct values", func() {
ginkgo.BeforeEach(func() {
tsPtr := reflect.New(tt)
tv = tsPtr.Elem()
ts = tsPtr.Interface().(*testStruct)
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
rootNode := getRootNode(ts)
nodeMap = make(map[string]Node, len(rootNode.Items))
for _, item := range rootNode.Items {
field := item.Field()
nodeMap[field.Name] = item
}
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
To(gomega.Equal(0), "TestEnum initial value")
})
ginkgo.When("setting and formatting", func() {
ginkgo.It("should format signed integers identical to input", func() {
testSetAndFormat(tv, nodeMap["Signed"], "-45", "-45")
})
ginkgo.It("should format unsigned integers identical to input", func() {
testSetAndFormat(tv, nodeMap["Unsigned"], "5", "5")
})
ginkgo.It("should format structs identical to input", func() {
testSetAndFormat(tv, nodeMap["SubProp"], "@whoa", "@whoa")
})
ginkgo.It("should format enums identical to input", func() {
testSetAndFormat(tv, nodeMap["TestEnum"], "Foo", "Foo")
})
ginkgo.It("should format string slices identical to input", func() {
testSetAndFormat(
tv,
nodeMap["StrSlice"],
"one,two,three,four",
"[ one, two, three, four ]",
)
})
ginkgo.It("should format string arrays identical to input", func() {
testSetAndFormat(tv, nodeMap["StrArray"], "one,two,three", "[ one, two, three ]")
})
ginkgo.It("should format prop struct slices identical to input", func() {
testSetAndFormat(
tv,
nodeMap["SubPropSlice"],
"@be,@the,@best",
"[ @be, @the, @best ]",
)
})
ginkgo.It("should format prop struct pointer slices identical to input", func() {
testSetAndFormat(tv, nodeMap["SubPropPtrSlice"], "@diet,@glue", "[ @diet, @glue ]")
})
ginkgo.It("should format string maps identical to input", func() {
testSetAndFormat(tv, nodeMap["StrMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int maps identical to input", func() {
testSetAndFormat(tv, nodeMap["IntMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int8 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int16 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int32 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int64 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint maps identical to input", func() {
testSetAndFormat(tv, nodeMap["UintMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint8 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint16 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint32 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint64 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
})
})
})
func testSetAndFormat(tv reflect.Value, node Node, value string, prettyFormat string) {
field := node.Field()
valid, err := SetConfigField(tv, *field, value)
if !valid {
gomega.Expect(err).To(gomega.HaveOccurred(), "SetConfigField returned false but no error")
}
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "SetConfigField error: %v", err)
gomega.Expect(valid).To(gomega.BeTrue(), "SetConfigField failed")
formatted, err := GetConfigFieldString(tv, *field)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "GetConfigFieldString error: %v", err)
gomega.Expect(formatted).To(gomega.Equal(value), "Expected %q, got %q", value, formatted)
node.Update(tv.FieldByName(field.Name))
sb := strings.Builder{}
renderer := ConsoleTreeRenderer{}
renderer.writeNodeValue(&sb, node)
gomega.Expect(sb.String()).To(gomega.Equal(prettyFormat))
}

390
pkg/format/node.go Normal file
View file

@ -0,0 +1,390 @@
package format
import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// NodeTokenType is used to represent the type of value that a node has for syntax highlighting.
type NodeTokenType int
const (
// UnknownToken represents all unknown/unspecified tokens.
UnknownToken NodeTokenType = iota
// NumberToken represents all numbers.
NumberToken
// StringToken represents strings and keys.
StringToken
// EnumToken represents enum values.
EnumToken
// TrueToken represent boolean true.
TrueToken
// FalseToken represent boolean false.
FalseToken
// PropToken represent a serializable struct prop.
PropToken
// ErrorToken represent a value that was not serializable or otherwise invalid.
ErrorToken
// ContainerToken is used for Array/Slice and Map tokens.
ContainerToken
)
// Constants for number bases.
const (
BaseDecimalLen = 10
BaseHexLen = 16
)
// Node is the generic config tree item.
type Node interface {
Field() *FieldInfo
TokenType() NodeTokenType
Update(tv reflect.Value)
}
// ValueNode is a Node without any child items.
type ValueNode struct {
*FieldInfo
Value string
tokenType NodeTokenType
}
// Field returns the inner FieldInfo.
func (n *ValueNode) Field() *FieldInfo {
return n.FieldInfo
}
// TokenType returns a NodeTokenType that matches the value.
func (n *ValueNode) TokenType() NodeTokenType {
return n.tokenType
}
// Update updates the value string from the provided value.
func (n *ValueNode) Update(tv reflect.Value) {
value, token := getValueNodeValue(tv, n.FieldInfo)
n.Value = value
n.tokenType = token
}
// ContainerNode is a Node with child items.
type ContainerNode struct {
*FieldInfo
Items []Node
MaxKeyLength int
}
// Field returns the inner FieldInfo.
func (n *ContainerNode) Field() *FieldInfo {
return n.FieldInfo
}
// TokenType always returns ContainerToken for ContainerNode.
func (n *ContainerNode) TokenType() NodeTokenType {
return ContainerToken
}
// Update updates the items to match the provided value.
func (n *ContainerNode) Update(tv reflect.Value) {
switch n.Type.Kind() {
case reflect.Array, reflect.Slice:
n.updateArrayNode(tv)
case reflect.Map:
n.updateMapNode(tv)
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
// No-op for unsupported kinds
default:
// No-op for any remaining kinds
}
}
func (n *ContainerNode) updateArrayNode(arrayValue reflect.Value) {
itemCount := arrayValue.Len()
n.Items = make([]Node, 0, itemCount)
elemType := arrayValue.Type().Elem()
for i := range itemCount {
key := strconv.Itoa(i)
val := arrayValue.Index(i)
n.Items = append(n.Items, getValueNode(val, &FieldInfo{
Name: key,
Type: elemType,
}))
}
}
func getArrayNode(arrayValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
node := &ContainerNode{
FieldInfo: fieldInfo,
MaxKeyLength: 0,
}
node.updateArrayNode(arrayValue)
return node
}
func sortNodeItems(nodeItems []Node) {
sort.Slice(nodeItems, func(i, j int) bool {
return nodeItems[i].Field().Name < nodeItems[j].Field().Name
})
}
func (n *ContainerNode) updateMapNode(mapValue reflect.Value) {
base := n.Base
if base == 0 {
base = BaseDecimalLen
}
elemType := mapValue.Type().Elem()
mapKeys := mapValue.MapKeys()
nodeItems := make([]Node, len(mapKeys))
maxKeyLength := 0
for i, keyVal := range mapKeys {
// The keys will always be strings
key := keyVal.String()
val := mapValue.MapIndex(keyVal)
nodeItems[i] = getValueNode(val, &FieldInfo{
Name: key,
Type: elemType,
Base: base,
})
maxKeyLength = util.Max(len(key), maxKeyLength)
}
sortNodeItems(nodeItems)
n.Items = nodeItems
n.MaxKeyLength = maxKeyLength
}
func getMapNode(mapValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
if mapValue.Kind() == reflect.Ptr {
mapValue = mapValue.Elem()
}
node := &ContainerNode{
FieldInfo: fieldInfo,
}
node.updateMapNode(mapValue)
return node
}
func getNode(fieldVal reflect.Value, fieldInfo *FieldInfo) Node {
switch fieldInfo.Type.Kind() {
case reflect.Array, reflect.Slice:
return getArrayNode(fieldVal, fieldInfo)
case reflect.Map:
return getMapNode(fieldVal, fieldInfo)
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return getValueNode(fieldVal, fieldInfo)
default:
return getValueNode(fieldVal, fieldInfo)
}
}
func getRootNode(value any) *ContainerNode {
structValue := reflect.ValueOf(value)
if structValue.Kind() == reflect.Ptr {
structValue = structValue.Elem()
}
structType := structValue.Type()
enums := map[string]types.EnumFormatter{}
if enummer, isEnummer := value.(types.Enummer); isEnummer {
enums = enummer.Enums()
}
infoFields := getStructFieldInfo(structType, enums)
nodeItems := make([]Node, 0, len(infoFields))
maxKeyLength := 0
for _, fieldInfo := range infoFields {
fieldValue := structValue.FieldByName(fieldInfo.Name)
if !fieldValue.IsValid() {
fieldValue = reflect.Zero(fieldInfo.Type)
}
nodeItems = append(nodeItems, getNode(fieldValue, &fieldInfo))
maxKeyLength = util.Max(len(fieldInfo.Name), maxKeyLength)
}
sortNodeItems(nodeItems)
return &ContainerNode{
FieldInfo: &FieldInfo{Type: structType},
Items: nodeItems,
MaxKeyLength: maxKeyLength,
}
}
func getValueNode(fieldVal reflect.Value, fieldInfo *FieldInfo) *ValueNode {
value, tokenType := getValueNodeValue(fieldVal, fieldInfo)
return &ValueNode{
FieldInfo: fieldInfo,
Value: value,
tokenType: tokenType,
}
}
func getValueNodeValue(fieldValue reflect.Value, fieldInfo *FieldInfo) (string, NodeTokenType) {
kind := fieldValue.Kind()
base := fieldInfo.Base
if base == 0 {
base = BaseDecimalLen
}
if fieldInfo.IsEnum() {
return fieldInfo.EnumFormatter.Print(int(fieldValue.Int())), EnumToken
}
switch kind {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
val := strconv.FormatUint(fieldValue.Uint(), base)
if base == BaseHexLen {
val = "0x" + val
}
return val, NumberToken
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(fieldValue.Int(), base), NumberToken
case reflect.String:
return fieldValue.String(), StringToken
case reflect.Bool:
val := fieldValue.Bool()
if val {
return PrintBool(val), TrueToken
}
return PrintBool(val), FalseToken
case reflect.Array, reflect.Slice, reflect.Map:
return getContainerValueString(fieldValue, fieldInfo), UnknownToken
case reflect.Ptr, reflect.Struct:
if val, err := GetConfigPropString(fieldValue); err == nil {
return val, PropToken
}
return "<ERR>", ErrorToken
case reflect.Invalid,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.UnsafePointer:
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
default:
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
}
}
func getContainerValueString(fieldValue reflect.Value, fieldInfo *FieldInfo) string {
itemSeparator := fieldInfo.ItemSeparator
sliceLength := fieldValue.Len()
var mapKeys []reflect.Value
if fieldInfo.Type.Kind() == reflect.Map {
mapKeys = fieldValue.MapKeys()
sort.Slice(mapKeys, func(a, b int) bool {
return mapKeys[a].String() < mapKeys[b].String()
})
}
stringBuilder := strings.Builder{}
var itemFieldInfo *FieldInfo
for i := range sliceLength {
var itemValue reflect.Value
if i > 0 {
stringBuilder.WriteRune(itemSeparator)
}
if mapKeys != nil {
mapKey := mapKeys[i]
stringBuilder.WriteString(mapKey.String())
stringBuilder.WriteRune(':')
itemValue = fieldValue.MapIndex(mapKey)
} else {
itemValue = fieldValue.Index(i)
}
if i == 0 {
itemFieldInfo = &FieldInfo{
Type: itemValue.Type(),
// Inherit the base from the container
Base: fieldInfo.Base,
}
if itemFieldInfo.Base == 0 {
itemFieldInfo.Base = BaseDecimalLen
}
}
strVal, _ := getValueNodeValue(itemValue, itemFieldInfo)
stringBuilder.WriteString(strVal)
}
return stringBuilder.String()
}

View file

@ -0,0 +1,176 @@
package format
import (
"errors"
"fmt"
"reflect"
"sort"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var (
ErrInvalidConfigKey = errors.New("not a valid config key")
ErrInvalidValueForType = errors.New("invalid value for type")
)
// PropKeyResolver implements the ConfigQueryResolver interface for services that uses key tags for query props.
type PropKeyResolver struct {
confValue reflect.Value
keyFields map[string]FieldInfo
keys []string
}
// NewPropKeyResolver creates a new PropKeyResolver and initializes it using the provided config.
func NewPropKeyResolver(config types.ServiceConfig) PropKeyResolver {
configNode := GetConfigFormat(config)
items := configNode.Items
keyFields := make(map[string]FieldInfo, len(items))
keys := make([]string, 0, len(items))
for _, item := range items {
field := *item.Field()
if len(field.Keys) == 0 {
continue // Skip fields without explicit 'key' tags
}
for _, key := range field.Keys {
key = strings.ToLower(key)
if key != "" {
keys = append(keys, key)
keyFields[key] = field
}
}
}
sort.Strings(keys)
confValue := reflect.ValueOf(config)
if confValue.Kind() == reflect.Ptr {
confValue = confValue.Elem()
}
return PropKeyResolver{
keyFields: keyFields,
confValue: confValue,
keys: keys,
}
}
// QueryFields returns a list of tagged keys.
func (pkr *PropKeyResolver) QueryFields() []string {
return pkr.keys
}
// Get returns the value of a config property tagged with the corresponding key.
func (pkr *PropKeyResolver) Get(key string) (string, error) {
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
return GetConfigFieldString(pkr.confValue, field)
}
return "", fmt.Errorf("%w: %v", ErrInvalidConfigKey, key)
}
// Set sets the value of its bound struct's property, tagged with the corresponding key.
func (pkr *PropKeyResolver) Set(key string, value string) error {
return pkr.set(pkr.confValue, key, value)
}
// set sets the value of a target struct tagged with the corresponding key.
func (pkr *PropKeyResolver) set(target reflect.Value, key string, value string) error {
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
valid, err := SetConfigField(target, field, value)
if !valid && err == nil {
return ErrInvalidValueForType
}
return err
}
return fmt.Errorf("%w: %v (valid keys: %v)", ErrInvalidConfigKey, key, pkr.keys)
}
// UpdateConfigFromParams mutates the provided config, updating the values from its corresponding params.
// If the provided config is nil, the internal config will be updated instead.
// The error returned is the first error that occurred; subsequent errors are discarded.
func (pkr *PropKeyResolver) UpdateConfigFromParams(
config types.ServiceConfig,
params *types.Params,
) error {
var firstError error
confValue := pkr.configValueOrInternal(config)
if params != nil {
for key, val := range *params {
if err := pkr.set(confValue, key, val); err != nil && firstError == nil {
firstError = err
}
}
}
return firstError
}
// SetDefaultProps mutates the provided config, setting the tagged fields with their default values.
// If the provided config is nil, the internal config will be updated instead.
// The error returned is the first error that occurred; subsequent errors are discarded.
func (pkr *PropKeyResolver) SetDefaultProps(config types.ServiceConfig) error {
var firstError error
confValue := pkr.configValueOrInternal(config)
for key, info := range pkr.keyFields {
if err := pkr.set(confValue, key, info.DefaultValue); err != nil && firstError == nil {
firstError = err
}
}
return firstError
}
// Bind creates a new instance of the PropKeyResolver with the internal config reference
// set to the provided config. This should only be used for configs of the same type.
func (pkr *PropKeyResolver) Bind(config types.ServiceConfig) PropKeyResolver {
bound := *pkr
bound.confValue = configValue(config)
return bound
}
// GetConfigQueryResolver returns the ConfigQueryResolver interface for the config if it implements it,
// otherwise it creates and returns a PropKeyResolver that implements it.
func GetConfigQueryResolver(config types.ServiceConfig) types.ConfigQueryResolver {
var resolver types.ConfigQueryResolver
var ok bool
if resolver, ok = config.(types.ConfigQueryResolver); !ok {
pkr := NewPropKeyResolver(config)
resolver = &pkr
}
return resolver
}
// KeyIsPrimary returns whether the key is the primary (and not an alias).
func (pkr *PropKeyResolver) KeyIsPrimary(key string) bool {
return pkr.keyFields[key].Keys[0] == key
}
func (pkr *PropKeyResolver) configValueOrInternal(config types.ServiceConfig) reflect.Value {
if config != nil {
return configValue(config)
}
return pkr.confValue
}
func configValue(config types.ServiceConfig) reflect.Value {
return reflect.Indirect(reflect.ValueOf(config))
}
// IsDefault returns whether the specified key value is the default value.
func (pkr *PropKeyResolver) IsDefault(key string, value string) bool {
return pkr.keyFields[key].DefaultValue == value
}

View file

@ -0,0 +1,58 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var _ = ginkgo.Describe("Prop Key Resolver", func() {
var (
ts *testStruct
pkr PropKeyResolver
)
ginkgo.BeforeEach(func() {
ts = &testStruct{}
pkr = NewPropKeyResolver(ts)
_ = pkr.SetDefaultProps(ts)
})
ginkgo.Describe("Updating config props from params", func() {
ginkgo.When("a param matches a prop key", func() {
ginkgo.It("should be updated in the config", func() {
err := pkr.UpdateConfigFromParams(nil, &types.Params{"str": "newValue"})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(ts.Str).To(gomega.Equal("newValue"))
})
})
ginkgo.When("a param does not match a prop key", func() {
ginkgo.It("should report the first error", func() {
err := pkr.UpdateConfigFromParams(nil, &types.Params{"a": "z"})
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should process the other keys", func() {
_ = pkr.UpdateConfigFromParams(
nil,
&types.Params{"signed": "1", "b": "c", "str": "val"},
)
gomega.Expect(ts.Signed).To(gomega.Equal(1))
gomega.Expect(ts.Str).To(gomega.Equal("val"))
})
})
})
ginkgo.Describe("Setting default props", func() {
ginkgo.When("a default tag are set for a field", func() {
ginkgo.It("should have that value as default", func() {
gomega.Expect(ts.Str).To(gomega.Equal("notempty"))
})
})
ginkgo.When("a default tag have an invalid value", func() {
ginkgo.It("should have that value as default", func() {
tsb := &testStructBadDefault{}
pkr = NewPropKeyResolver(tsb)
err := pkr.SetDefaultProps(tsb)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
})

View file

@ -0,0 +1,201 @@
package format
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Constants for console rendering.
const (
DescriptionColumnWidth = 60 // Width of the description column in console output
ItemSeparatorLength = 2 // Length of the ", " separator between container items
DefaultValueOffset = 16 // Minimum offset before description when no values are shown
ValueOffset = 30 // Offset before description when values are shown
ContainerBracketLength = 4 // Length of container delimiters (e.g., "{ }" or "[ ]")
KeySeparatorLength = 2 // Length of the ": " separator after a key in containers
)
// ConsoleTreeRenderer renders a ContainerNode tree into an ansi-colored console string.
type ConsoleTreeRenderer struct {
WithValues bool
}
// RenderTree renders a ContainerNode tree into an ansi-colored console string.
func (r ConsoleTreeRenderer) RenderTree(root *ContainerNode, _ string) string {
stringBuilder := strings.Builder{}
for _, node := range root.Items {
fieldKey := node.Field().Name
stringBuilder.WriteString(fieldKey)
for i := len(fieldKey); i <= root.MaxKeyLength; i++ {
stringBuilder.WriteRune(' ')
}
var valueLen int // Initialize without assignment; set later
preLen := DefaultValueOffset // Default spacing before the description when no values are rendered
field := node.Field()
if r.WithValues {
preLen = ValueOffset // Adjusts the spacing when values are included
valueLen = r.writeNodeValue(&stringBuilder, node)
} else {
// Since no values were supplied, substitute the value with the type
typeName := field.Type.String()
// If the value is an enum type, providing the name is a bit pointless
// Instead, use a common string "option" to signify the type
if field.EnumFormatter != nil {
typeName = "option"
}
valueLen = len(typeName)
stringBuilder.WriteString(color.CyanString(typeName))
}
stringBuilder.WriteString(strings.Repeat(" ", util.Max(preLen-valueLen, 1)))
stringBuilder.WriteString(ColorizeDesc(field.Description))
stringBuilder.WriteString(
strings.Repeat(" ", util.Max(DescriptionColumnWidth-len(field.Description), 1)),
)
if len(field.URLParts) > 0 && field.URLParts[0] != URLQuery {
stringBuilder.WriteString(" <URL: ")
for i, part := range field.URLParts {
if i > 0 {
stringBuilder.WriteString(", ")
}
if part > URLPath {
part = URLPath
}
stringBuilder.WriteString(ColorizeEnum(part))
}
stringBuilder.WriteString(">")
}
if len(field.Template) > 0 {
stringBuilder.WriteString(
fmt.Sprintf(" <Template: %s>", ColorizeString(field.Template)),
)
}
if len(field.DefaultValue) > 0 {
stringBuilder.WriteString(
fmt.Sprintf(
" <Default: %s>",
ColorizeValue(field.DefaultValue, field.EnumFormatter != nil),
),
)
}
if field.Required {
stringBuilder.WriteString(fmt.Sprintf(" <%s>", ColorizeFalse("Required")))
}
if len(field.Keys) > 1 {
stringBuilder.WriteString(" <Aliases: ")
for i, key := range field.Keys {
if i == 0 {
// Skip primary alias (as it's the same as the field name)
continue
}
if i > 1 {
stringBuilder.WriteString(", ")
}
stringBuilder.WriteString(ColorizeString(key))
}
stringBuilder.WriteString(">")
}
if field.EnumFormatter != nil {
stringBuilder.WriteString(ColorizeContainer(" ["))
for i, name := range field.EnumFormatter.Names() {
if i != 0 {
stringBuilder.WriteString(", ")
}
stringBuilder.WriteString(ColorizeEnum(name))
}
stringBuilder.WriteString(ColorizeContainer("]"))
}
stringBuilder.WriteRune('\n')
}
return stringBuilder.String()
}
func (r ConsoleTreeRenderer) writeNodeValue(stringBuilder *strings.Builder, node Node) int {
if contNode, isContainer := node.(*ContainerNode); isContainer {
return r.writeContainer(stringBuilder, contNode)
}
if valNode, isValue := node.(*ValueNode); isValue {
stringBuilder.WriteString(ColorizeToken(valNode.Value, valNode.tokenType))
return len(valNode.Value)
}
stringBuilder.WriteRune('?')
return 1
}
func (r ConsoleTreeRenderer) writeContainer(
stringBuilder *strings.Builder,
node *ContainerNode,
) int {
kind := node.Type.Kind()
hasKeys := !util.IsCollection(kind)
totalLen := ContainerBracketLength // Length of the opening and closing brackets ({ } or [ ])
if hasKeys {
stringBuilder.WriteString("{ ")
} else {
stringBuilder.WriteString("[ ")
}
for i, itemNode := range node.Items {
if i != 0 {
stringBuilder.WriteString(", ")
totalLen += KeySeparatorLength // Accounts for the : separator between keys and values in containers
}
if hasKeys {
itemKey := itemNode.Field().Name
stringBuilder.WriteString(itemKey)
stringBuilder.WriteString(": ")
totalLen += len(itemKey) + ItemSeparatorLength
}
valLen := r.writeNodeValue(stringBuilder, itemNode)
totalLen += valLen
}
if hasKeys {
stringBuilder.WriteString(" }")
} else {
stringBuilder.WriteString(" ]")
}
return totalLen
}

View file

@ -0,0 +1,54 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
)
var _ = ginkgo.Describe("RenderConsole", func() {
format.CharactersAroundMismatchToInclude = 30
renderer := ConsoleTreeRenderer{WithValues: false}
ginkgo.It("should render the expected output based on config reflection/tags", func() {
actual := testRenderTree(renderer, &struct {
Name string `default:"notempty"`
Host string `url:"host"`
}{})
expected := `
Host string <URL: Host> <Required>
Name string <Default: notempty>
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It(`should render enum types as "option"`, func() {
actual := testRenderTree(renderer, &testEnummer{})
expected := `
Choice option <Default: Maybe> [Yes, No, Maybe]
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render url paths in sorted order", func() {
actual := testRenderTree(renderer, &struct {
Host string `url:"host"`
Path1 string `url:"path1"`
Path3 string `url:"path3"`
Path2 string `url:"path2"`
}{})
expected := `
Host string <URL: Host> <Required>
Path1 string <URL: Path> <Required>
Path2 string <URL: Path> <Required>
Path3 string <URL: Path> <Required>
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
})

View file

@ -0,0 +1,259 @@
package format
import (
"reflect"
"sort"
"strings"
)
// MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string.
type MarkdownTreeRenderer struct {
HeaderPrefix string
PropsDescription string
PropsEmptyMessage string
}
// Constants for dynamic path segment offsets.
const (
PathOffset1 = 1
PathOffset2 = 2
PathOffset3 = 3
)
// RenderTree renders a ContainerNode tree into a markdown documentation string.
func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) string {
stringBuilder := strings.Builder{}
queryFields := make([]*FieldInfo, 0, len(root.Items))
urlFields := make([]*FieldInfo, 0, len(root.Items)) // Zero length, capacity for all fields
dynamicURLFields := make([]*FieldInfo, 0, len(root.Items))
for _, node := range root.Items {
field := node.Field()
for _, urlPart := range field.URLParts {
switch urlPart {
case URLQuery:
queryFields = append(queryFields, field)
case URLPath + PathOffset1,
URLPath + PathOffset2,
URLPath + PathOffset3:
dynamicURLFields = append(dynamicURLFields, field)
case URLUser, URLPassword, URLHost, URLPort, URLPath:
urlFields = append(urlFields, field)
}
}
if len(field.URLParts) < 1 {
queryFields = append(queryFields, field)
}
}
// Append dynamic fields to urlFields
urlFields = append(urlFields, dynamicURLFields...)
// Sort by primary URLPart
sort.SliceStable(urlFields, func(i, j int) bool {
urlPartA := URLQuery
if len(urlFields[i].URLParts) > 0 {
urlPartA = urlFields[i].URLParts[0]
}
urlPartB := URLQuery
if len(urlFields[j].URLParts) > 0 {
urlPartB = urlFields[j].URLParts[0]
}
return urlPartA < urlPartB
})
r.writeURLFields(&stringBuilder, urlFields, scheme)
sort.SliceStable(queryFields, func(i, j int) bool {
return queryFields[i].Required && !queryFields[j].Required
})
r.writeHeader(&stringBuilder, "Query/Param Props")
if len(queryFields) > 0 {
stringBuilder.WriteString(r.PropsDescription)
} else {
stringBuilder.WriteString(r.PropsEmptyMessage)
}
stringBuilder.WriteRune('\n')
for _, field := range queryFields {
r.writeFieldPrimary(&stringBuilder, field)
r.writeFieldExtras(&stringBuilder, field)
stringBuilder.WriteRune('\n')
}
return stringBuilder.String()
}
func (r MarkdownTreeRenderer) writeURLFields(
stringBuilder *strings.Builder,
urlFields []*FieldInfo,
scheme string,
) {
fieldsPrinted := make(map[string]bool)
r.writeHeader(stringBuilder, "URL Fields")
for _, field := range urlFields {
if field == nil || fieldsPrinted[field.Name] {
continue
}
r.writeFieldPrimary(stringBuilder, field)
stringBuilder.WriteString(" URL part: <code class=\"service-url\">")
stringBuilder.WriteString(scheme)
stringBuilder.WriteString("://")
// Check for presence of URLUser or URLPassword
hasUser := false
hasPassword := false
maxPart := URLUser // Track the highest URLPart used
for _, f := range urlFields {
if f != nil {
for _, part := range f.URLParts {
switch part {
case URLQuery, URLHost, URLPort, URLPath: // No-op for these cases
case URLUser:
hasUser = true
case URLPassword:
hasPassword = true
}
if part > maxPart {
maxPart = part
}
}
}
}
// Build URL with this field highlighted
for i := URLUser; i <= URLPath+PathOffset3; i++ {
urlPart := i
for _, fieldInfo := range urlFields {
if fieldInfo != nil && fieldInfo.IsURLPart(urlPart) {
if i > URLUser {
lastPart := i - 1
if lastPart == URLPassword && (hasUser || hasPassword) {
stringBuilder.WriteRune(
lastPart.Suffix(),
) // ':' only if credentials present
} else if lastPart != URLPassword {
stringBuilder.WriteRune(lastPart.Suffix()) // '/' or '@'
}
}
slug := strings.ToLower(fieldInfo.Name)
if slug == "host" && urlPart == URLPort {
slug = "port"
}
if fieldInfo == field {
stringBuilder.WriteString("<strong>")
stringBuilder.WriteString(slug)
stringBuilder.WriteString("</strong>")
} else {
stringBuilder.WriteString(slug)
}
break
}
}
}
// Add trailing '/' if no dynamic path segments follow
if maxPart < URLPath+PathOffset1 {
stringBuilder.WriteRune('/')
}
stringBuilder.WriteString("</code> \n")
fieldsPrinted[field.Name] = true
}
}
func (MarkdownTreeRenderer) writeFieldExtras(stringBuilder *strings.Builder, field *FieldInfo) {
if len(field.Keys) > 1 {
stringBuilder.WriteString(" Aliases: `")
for i, key := range field.Keys {
if i == 0 {
// Skip primary alias (as it's the same as the field name)
continue
}
if i > 1 {
stringBuilder.WriteString("`, `")
}
stringBuilder.WriteString(key)
}
stringBuilder.WriteString("` \n")
}
if field.EnumFormatter != nil {
stringBuilder.WriteString(" Possible values: `")
for i, name := range field.EnumFormatter.Names() {
if i != 0 {
stringBuilder.WriteString("`, `")
}
stringBuilder.WriteString(name)
}
stringBuilder.WriteString("` \n")
}
}
func (MarkdownTreeRenderer) writeFieldPrimary(stringBuilder *strings.Builder, field *FieldInfo) {
fieldKey := field.Name
stringBuilder.WriteString("* __")
stringBuilder.WriteString(fieldKey)
stringBuilder.WriteString("__")
if field.Description != "" {
stringBuilder.WriteString(" - ")
stringBuilder.WriteString(field.Description)
}
if field.Required {
stringBuilder.WriteString(" (**Required**) \n")
} else {
stringBuilder.WriteString(" \n Default: ")
if field.DefaultValue == "" {
stringBuilder.WriteString("*empty*")
} else {
if field.Type.Kind() == reflect.Bool {
defaultValue, _ := ParseBool(field.DefaultValue, false)
if defaultValue {
stringBuilder.WriteString("✔ ")
} else {
stringBuilder.WriteString("❌ ")
}
}
stringBuilder.WriteRune('`')
stringBuilder.WriteString(field.DefaultValue)
stringBuilder.WriteRune('`')
}
stringBuilder.WriteString(" \n")
}
}
func (r MarkdownTreeRenderer) writeHeader(stringBuilder *strings.Builder, text string) {
stringBuilder.WriteString(r.HeaderPrefix)
stringBuilder.WriteString(text)
stringBuilder.WriteString("\n\n")
}

View file

@ -0,0 +1,149 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
)
var _ = ginkgo.Describe("RenderMarkdown", func() {
format.CharactersAroundMismatchToInclude = 10
ginkgo.It("should render the expected output based on config reflection/tags", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Name string `default:"notempty"`
Host string `url:"host"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
* __Name__
Default: `[1:] + "`notempty`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render url paths in sorted order", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Host string `url:"host"`
Path1 string `url:"path1"`
Path3 string `url:"path3"`
Path2 string `url:"path2"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/path1/path2/path3</code>
* __Path1__ (**Required**)
URL part: <code class="service-url">mock://host/<strong>path1</strong>/path2/path3</code>
* __Path2__ (**Required**)
URL part: <code class="service-url">mock://host/path1/<strong>path2</strong>/path3</code>
* __Path3__ (**Required**)
URL part: <code class="service-url">mock://host/path1/path2/<strong>path3</strong></code>
### Query/Param Props
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render prop aliases", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Name string `key:"name,handle,title,target"`
}{})
expected := `
### URL Fields
### Query/Param Props
* __Name__ (**Required**)
Aliases: `[1:] + "`handle`, `title`, `target`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render possible enum values", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &testEnummer{})
expected := `
### URL Fields
### Query/Param Props
* __Choice__
Default: `[1:] + "`Maybe`" + `
Possible values: ` + "`Yes`, `No`, `Maybe`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.When("there are no query props", func() {
ginkgo.It("should prepend an empty-message instead of props description", func() {
actual := testRenderTree(MarkdownTreeRenderer{
HeaderPrefix: `### `,
PropsDescription: "Feel free to set these:",
PropsEmptyMessage: "There is nothing to set!",
}, &struct {
Host string `url:"host"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
There is nothing to set!
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
})
ginkgo.When("there are query props", func() {
ginkgo.It("should prepend the props description", func() {
actual := testRenderTree(MarkdownTreeRenderer{
HeaderPrefix: `### `,
PropsDescription: "Feel free to set these:",
PropsEmptyMessage: "There is nothing to set!",
}, &struct {
Host string `url:"host"`
CoolMode bool `key:"coolmode" optional:""`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
Feel free to set these:
* __CoolMode__
Default: *empty*
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
})
})

17
pkg/format/render_test.go Normal file
View file

@ -0,0 +1,17 @@
package format
import t "github.com/nicholas-fedor/shoutrrr/pkg/types"
type testEnummer struct {
Choice int `default:"Maybe" key:"choice"`
}
func (testEnummer) Enums() map[string]t.EnumFormatter {
return map[string]t.EnumFormatter{
"Choice": CreateEnumFormatter([]string{"Yes", "No", "Maybe"}),
}
}
func testRenderTree(r TreeRenderer, v any) string {
return r.RenderTree(getRootNode(v), "mock")
}

View file

@ -0,0 +1,6 @@
package format
// TreeRenderer renders a ContainerNode tree into a string.
type TreeRenderer interface {
RenderTree(root *ContainerNode, scheme string) string
}

84
pkg/format/urlpart.go Normal file
View file

@ -0,0 +1,84 @@
//go:generate stringer -type=URLPart -trimprefix URL
package format
import (
"log"
"strconv"
"strings"
)
// URLPart is an indicator as to what part of an URL a field is serialized to.
type URLPart int
// Suffix returns the separator between the URLPart and its subsequent part.
func (u URLPart) Suffix() rune {
switch u {
case URLQuery:
return '/'
case URLUser:
return ':'
case URLPassword:
return '@'
case URLHost:
return ':'
case URLPort:
return '/'
case URLPath:
return '/'
default:
return '/'
}
}
// indicator as to what part of an URL a field is serialized to.
const (
URLQuery URLPart = iota
URLUser
URLPassword
URLHost
URLPort
URLPath // Base path; additional paths are URLPath + N
)
// ParseURLPart returns the URLPart that matches the supplied string.
func ParseURLPart(inputString string) URLPart {
lowerString := strings.ToLower(inputString)
switch lowerString {
case "user":
return URLUser
case "pass", "password":
return URLPassword
case "host":
return URLHost
case "port":
return URLPort
case "path", "path1":
return URLPath
case "query", "":
return URLQuery
}
// Handle dynamic path segments (e.g., "path2", "path3", etc.).
if strings.HasPrefix(lowerString, "path") && len(lowerString) > 4 {
if num, err := strconv.Atoi(lowerString[4:]); err == nil && num >= 2 {
return URLPath + URLPart(num-1) // Offset from URLPath; "path2" -> URLPath+1
}
}
log.Printf("invalid URLPart: %s, defaulting to URLQuery", lowerString)
return URLQuery
}
// ParseURLParts returns the URLParts that matches the supplied string.
func ParseURLParts(s string) []URLPart {
rawParts := strings.Split(s, ",")
urlParts := make([]URLPart, len(rawParts))
for i, raw := range rawParts {
urlParts[i] = ParseURLPart(raw)
}
return urlParts
}

View file

@ -0,0 +1,28 @@
// Code generated by "stringer -type=URLPart -trimprefix URL"; DO NOT EDIT.
package format
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[URLQuery-0]
_ = x[URLUser-1]
_ = x[URLPassword-2]
_ = x[URLHost-3]
_ = x[URLPort-4]
_ = x[URLPath-5]
}
const _URLPart_name = "QueryUserPasswordHostPortPath"
var _URLPart_index = [...]uint8{0, 5, 9, 17, 21, 25, 29}
func (i URLPart) String() string {
if i < 0 || i >= URLPart(len(_URLPart_index)-1) {
return "URLPart(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _URLPart_name[_URLPart_index[i]:_URLPart_index[i+1]]
}

View file

@ -0,0 +1,32 @@
package format_test
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
)
var _ = ginkgo.Describe("URLPart", func() {
ginkgo.It("should return the expected URL part for each lookup key", func() {
gomega.Expect(format.ParseURLPart("user")).To(gomega.Equal(format.URLUser))
gomega.Expect(format.ParseURLPart("pass")).To(gomega.Equal(format.URLPassword))
gomega.Expect(format.ParseURLPart("password")).To(gomega.Equal(format.URLPassword))
gomega.Expect(format.ParseURLPart("host")).To(gomega.Equal(format.URLHost))
gomega.Expect(format.ParseURLPart("port")).To(gomega.Equal(format.URLPort))
gomega.Expect(format.ParseURLPart("path")).To(gomega.Equal(format.URLPath))
gomega.Expect(format.ParseURLPart("path1")).To(gomega.Equal(format.URLPath))
gomega.Expect(format.ParseURLPart("path2")).To(gomega.Equal(format.URLPath + 1))
gomega.Expect(format.ParseURLPart("path3")).To(gomega.Equal(format.URLPath + 2))
gomega.Expect(format.ParseURLPart("path4")).To(gomega.Equal(format.URLPath + 3))
gomega.Expect(format.ParseURLPart("query")).To(gomega.Equal(format.URLQuery))
gomega.Expect(format.ParseURLPart("")).To(gomega.Equal(format.URLQuery))
})
ginkgo.It("should return the expected suffix for each URL part", func() {
gomega.Expect(format.URLUser.Suffix()).To(gomega.Equal(':'))
gomega.Expect(format.URLPassword.Suffix()).To(gomega.Equal('@'))
gomega.Expect(format.URLHost.Suffix()).To(gomega.Equal(':'))
gomega.Expect(format.URLPort.Suffix()).To(gomega.Equal('/'))
gomega.Expect(format.URLPath.Suffix()).To(gomega.Equal('/'))
})
})

View file

@ -0,0 +1,219 @@
package basic
import (
"bufio"
"errors"
"fmt"
"os"
"reflect"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Errors defined as static variables for better error handling.
var (
ErrInvalidConfigType = errors.New("config does not implement types.ServiceConfig")
ErrInvalidConfigField = errors.New("config field is invalid or nil")
ErrRequiredFieldMissing = errors.New("field is required and has no default value")
)
// Generator is the Basic Generator implementation for creating service configurations.
type Generator struct{}
// Generate creates a service configuration by prompting the user for field values or using provided properties.
func (g *Generator) Generate(
service types.Service,
props map[string]string,
_ []string,
) (types.ServiceConfig, error) {
configPtr := reflect.ValueOf(service).Elem().FieldByName("Config")
if !configPtr.IsValid() || configPtr.IsNil() {
return nil, ErrInvalidConfigField
}
scanner := bufio.NewScanner(os.Stdin)
if err := g.promptUserForFields(configPtr, props, scanner); err != nil {
return nil, err
}
if config, ok := configPtr.Interface().(types.ServiceConfig); ok {
return config, nil
}
return nil, ErrInvalidConfigType
}
// promptUserForFields iterates over config fields, prompting the user or using props to set values.
func (g *Generator) promptUserForFields(
configPtr reflect.Value,
props map[string]string,
scanner *bufio.Scanner,
) error {
serviceConfig, ok := configPtr.Interface().(types.ServiceConfig)
if !ok {
return ErrInvalidConfigType
}
configNode := format.GetConfigFormat(serviceConfig)
config := configPtr.Elem() // Dereference for setting fields
for _, item := range configNode.Items {
field := item.Field()
propKey := strings.ToLower(field.Name)
for {
inputValue, err := g.getInputValue(field, propKey, props, scanner)
if err != nil {
return err // Propagate the error immediately
}
if valid, err := g.setFieldValue(config, field, inputValue); valid {
break
} else if err != nil {
g.printError(field.Name, err.Error())
} else {
g.printInvalidType(field.Name, field.Type.Kind().String())
}
}
}
return nil
}
// getInputValue retrieves the value for a field from props or user input.
func (g *Generator) getInputValue(
field *format.FieldInfo,
propKey string,
props map[string]string,
scanner *bufio.Scanner,
) (string, error) {
if propValue, ok := props[propKey]; ok && len(propValue) > 0 {
_, _ = fmt.Fprint(
color.Output,
"Using property ",
color.HiCyanString(propValue),
" for ",
color.HiMagentaString(field.Name),
" field\n",
)
props[propKey] = ""
return propValue, nil
}
prompt := g.formatPrompt(field)
_, _ = fmt.Fprint(color.Output, prompt)
if scanner.Scan() {
input := scanner.Text()
if len(input) == 0 {
if len(field.DefaultValue) > 0 {
return field.DefaultValue, nil
}
if field.Required {
return "", fmt.Errorf("%s: %w", field.Name, ErrRequiredFieldMissing)
}
return "", nil
}
// More specific type validation
if field.Type != nil {
kind := field.Type.Kind()
if kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 ||
kind == reflect.Int32 || kind == reflect.Int64 {
if _, err := strconv.ParseInt(input, 10, field.Type.Bits()); err != nil {
return "", fmt.Errorf("invalid integer value for %s: %w", field.Name, err)
}
}
}
return input, nil
} else if scanErr := scanner.Err(); scanErr != nil {
return "", fmt.Errorf("scanner error: %w", scanErr)
}
return field.DefaultValue, nil
}
// formatPrompt creates a user prompt based on the fields name and default value.
func (g *Generator) formatPrompt(field *format.FieldInfo) string {
if len(field.DefaultValue) > 0 {
return fmt.Sprintf("%s[%s]: ", color.HiWhiteString(field.Name), field.DefaultValue)
}
return color.HiWhiteString(field.Name) + ": "
}
// setFieldValue attempts to set a fields value and handles required field validation.
func (g *Generator) setFieldValue(
config reflect.Value,
field *format.FieldInfo,
inputValue string,
) (bool, error) {
if len(inputValue) == 0 {
if field.Required {
_, _ = fmt.Fprint(
color.Output,
"Field ",
color.HiCyanString(field.Name),
" is required!\n\n",
)
return false, nil
}
if len(field.DefaultValue) == 0 {
return true, nil
}
inputValue = field.DefaultValue
}
valid, err := format.SetConfigField(config, *field, inputValue)
if err != nil {
return false, fmt.Errorf("failed to set field %s: %w", field.Name, err)
}
return valid, nil
}
// printError displays an error message for an invalid field value.
func (g *Generator) printError(fieldName, errorMsg string) {
_, _ = fmt.Fprint(
color.Output,
"Invalid format for field ",
color.HiCyanString(fieldName),
": ",
errorMsg,
"\n\n",
)
}
// printInvalidType displays a type mismatch error for a field.
func (g *Generator) printInvalidType(fieldName, typeName string) {
_, _ = fmt.Fprint(
color.Output,
"Invalid type ",
color.HiYellowString(typeName),
" for field ",
color.HiCyanString(fieldName),
"\n\n",
)
}
// validateAndReturnConfig ensures the config implements ServiceConfig and returns it.
func (g *Generator) validateAndReturnConfig(config reflect.Value) (types.ServiceConfig, error) {
configInterface := config.Interface()
if serviceConfig, ok := configInterface.(types.ServiceConfig); ok {
return serviceConfig, nil
}
return nil, ErrInvalidConfigType
}

View file

@ -0,0 +1,543 @@
package basic
import (
"bufio"
"fmt"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"testing"
"text/template"
"github.com/fatih/color"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// mockConfig implements types.ServiceConfig.
type mockConfig struct {
Host string `default:"localhost" key:"host"`
Port int `default:"8080" key:"port" required:"true"`
url *url.URL
}
func (m *mockConfig) Enums() map[string]types.EnumFormatter {
return nil
}
func (m *mockConfig) GetURL() *url.URL {
if m.url == nil {
u, _ := url.Parse("mock://url")
m.url = u
}
return m.url
}
func (m *mockConfig) SetURL(u *url.URL) error {
m.url = u
return nil
}
func (m *mockConfig) SetTemplateFile(_ string, _ string) error {
return nil
}
func (m *mockConfig) SetTemplateString(_ string, _ string) error {
return nil
}
func (m *mockConfig) SetLogger(_ types.StdLogger) {
// Minimal implementation, no-op
}
// ConfigQueryResolver methods.
func (m *mockConfig) Get(key string) (string, error) {
switch strings.ToLower(key) {
case "host":
return m.Host, nil
case "port":
return strconv.Itoa(m.Port), nil
default:
return "", fmt.Errorf("unknown key: %s", key)
}
}
func (m *mockConfig) Set(key string, value string) error {
switch strings.ToLower(key) {
case "host":
m.Host = value
return nil
case "port":
port, err := strconv.Atoi(value)
if err != nil {
return err
}
m.Port = port
return nil
default:
return fmt.Errorf("unknown key: %s", key)
}
}
func (m *mockConfig) QueryFields() []string {
return []string{"host", "port"}
}
// mockServiceConfig is a test implementation of Service.
type mockServiceConfig struct {
Config *mockConfig
}
func (m *mockServiceConfig) GetID() string {
return "mockID"
}
func (m *mockServiceConfig) GetTemplate(_ string) (*template.Template, bool) {
return nil, false
}
func (m *mockServiceConfig) SetTemplateFile(_ string, _ string) error {
return nil
}
func (m *mockServiceConfig) SetTemplateString(_ string, _ string) error {
return nil
}
func (m *mockServiceConfig) Initialize(_ *url.URL, _ types.StdLogger) error {
return nil
}
func (m *mockServiceConfig) Send(_ string, _ *types.Params) error {
return nil
}
func (m *mockServiceConfig) SetLogger(_ types.StdLogger) {}
// ConfigProp methods.
func (m *mockConfig) SetFromProp(propValue string) error {
// Minimal implementation for testing; typically parses propValue
parts := strings.SplitN(propValue, ":", 2)
if len(parts) == 2 {
m.Host = parts[0]
port, err := strconv.Atoi(parts[1])
if err != nil {
return err
}
m.Port = port
}
return nil
}
func (m *mockConfig) GetPropValue() (string, error) {
// Minimal implementation for testing
return fmt.Sprintf("%s:%d", m.Host, m.Port), nil
}
// newMockServiceConfig creates a new mockServiceConfig with an initialized Config.
func newMockServiceConfig() *mockServiceConfig {
return &mockServiceConfig{
Config: &mockConfig{},
}
}
func TestGenerator_Generate(t *testing.T) {
tests := []struct {
name string
props map[string]string
input string
want types.ServiceConfig
wantErr bool
}{
{
name: "successful generation with defaults",
props: map[string]string{},
input: "\n8080\n",
want: &mockConfig{
Host: "localhost",
Port: 8080,
},
wantErr: false,
},
{
name: "successful generation with props",
props: map[string]string{"host": "example.com", "port": "9090"},
input: "",
want: &mockConfig{
Host: "example.com",
Port: 9090,
},
wantErr: false,
},
{
name: "error_on_invalid_port",
props: map[string]string{},
input: "\ninvalid\n",
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
// Set up pipe for stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
originalStdin := os.Stdin
os.Stdin = r
defer func() {
os.Stdin = originalStdin
w.Close()
}()
// Write input to the pipe
_, err = w.WriteString(tt.input)
if err != nil {
t.Fatal(err)
}
w.Close()
service := newMockServiceConfig()
color.NoColor = true
got, err := g.Generate(service, tt.props, nil)
if (err != nil) != tt.wantErr {
t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && !reflect.DeepEqual(got, tt.want) {
t.Errorf("Generate() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestGenerator_promptUserForFields(t *testing.T) {
tests := []struct {
name string
config reflect.Value
props map[string]string
input string
wantErr bool
}{
{
name: "valid input with defaults",
config: reflect.ValueOf(newMockServiceConfig().Config), // Pass *mockConfig
props: map[string]string{},
input: "\n8080\n",
wantErr: false,
},
{
name: "valid props",
config: reflect.ValueOf(newMockServiceConfig().Config), // Pass *mockConfig
props: map[string]string{"host": "test.com", "port": "1234"},
input: "",
wantErr: false,
},
{
name: "invalid config type",
config: reflect.ValueOf("not a config"),
props: map[string]string{},
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
scanner := bufio.NewScanner(strings.NewReader(tt.input))
color.NoColor = true
err := g.promptUserForFields(tt.config, tt.props, scanner)
if (err != nil) != tt.wantErr {
t.Errorf("promptUserForFields() error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && tt.config.Kind() == reflect.Ptr &&
tt.config.Type().Elem().Kind() == reflect.Struct {
got := tt.config.Interface().(*mockConfig)
if tt.props["host"] != "" && got.Host != tt.props["host"] {
t.Errorf("promptUserForFields() host = %v, want %v", got.Host, tt.props["host"])
}
if tt.props["port"] != "" {
wantPort := atoiOrZero(tt.props["port"])
if got.Port != wantPort {
t.Errorf("promptUserForFields() port = %v, want %v", got.Port, wantPort)
}
}
}
})
}
}
func TestGenerator_getInputValue(t *testing.T) {
tests := []struct {
name string
field *format.FieldInfo
propKey string
props map[string]string
input string
want string
wantErr bool
}{
{
name: "from props",
field: &format.FieldInfo{Name: "Host"},
propKey: "host",
props: map[string]string{"host": "example.com"},
input: "",
want: "example.com",
wantErr: false,
},
{
name: "from user input",
field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0)}, // Add Type
propKey: "port",
props: map[string]string{},
input: "8080\n",
want: "8080",
wantErr: false,
},
{
name: "default value",
field: &format.FieldInfo{Name: "Host", DefaultValue: "localhost"},
propKey: "host",
props: map[string]string{},
input: "\n",
want: "localhost",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
scanner := bufio.NewScanner(strings.NewReader(tt.input))
color.NoColor = true
got, err := g.getInputValue(tt.field, tt.propKey, tt.props, scanner)
if (err != nil) != tt.wantErr {
t.Errorf("getInputValue() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("getInputValue() = %v, want %v", got, tt.want)
}
if tt.props[tt.propKey] != "" {
t.Errorf("getInputValue() did not clear prop, got %v", tt.props[tt.propKey])
}
})
}
}
func TestGenerator_formatPrompt(t *testing.T) {
tests := []struct {
name string
field *format.FieldInfo
want string
}{
{
name: "field with default",
field: &format.FieldInfo{Name: "Host", DefaultValue: "localhost"},
want: "\x1b[97mHost\x1b[0m[localhost]: ",
},
{
name: "field without default",
field: &format.FieldInfo{Name: "Port"},
want: "\x1b[97mPort\x1b[0m: ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
color.NoColor = false
got := g.formatPrompt(tt.field)
if got != tt.want {
t.Errorf("formatPrompt() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerator_setFieldValue(t *testing.T) {
tests := []struct {
name string
config reflect.Value
field *format.FieldInfo
inputValue string
want bool
wantErr bool
}{
{
name: "valid value",
config: reflect.ValueOf(newMockServiceConfig().Config).Elem(),
field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0), Required: true},
inputValue: "8080",
want: true,
wantErr: false,
},
{
name: "required field empty",
config: reflect.ValueOf(newMockServiceConfig().Config).Elem(),
field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0), Required: true},
inputValue: "",
want: false,
wantErr: false,
},
{
name: "invalid value",
config: reflect.ValueOf(newMockServiceConfig().Config).Elem(),
field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0)},
inputValue: "invalid",
want: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
color.NoColor = true
got, err := g.setFieldValue(tt.config, tt.field, tt.inputValue)
if (err != nil) != tt.wantErr {
t.Errorf("setFieldValue() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("setFieldValue() = %v, want %v", got, tt.want)
}
if got && !tt.wantErr {
if tt.field.Name == "Port" {
wantPort := atoiOrZero(tt.inputValue)
if gotPort := tt.config.FieldByName("Port").Int(); int(gotPort) != wantPort {
t.Errorf("setFieldValue() set Port = %v, want %v", gotPort, wantPort)
}
}
}
})
}
}
func TestGenerator_printError(t *testing.T) {
tests := []struct {
name string
fieldName string
errorMsg string
}{
{
name: "basic error",
fieldName: "Port",
errorMsg: "invalid format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(*testing.T) {
g := &Generator{}
color.NoColor = true
g.printError(tt.fieldName, tt.errorMsg)
})
}
}
func TestGenerator_printInvalidType(t *testing.T) {
tests := []struct {
name string
fieldName string
typeName string
}{
{
name: "invalid type",
fieldName: "Port",
typeName: "int",
},
}
for _, tt := range tests {
t.Run(tt.name, func(*testing.T) {
g := &Generator{}
color.NoColor = true
g.printInvalidType(tt.fieldName, tt.typeName)
})
}
}
func TestGenerator_validateAndReturnConfig(t *testing.T) {
tests := []struct {
name string
config reflect.Value
want types.ServiceConfig
wantErr bool
}{
{
name: "valid config",
config: reflect.ValueOf(&mockConfig{Host: "test", Port: 1234}),
want: &mockConfig{Host: "test", Port: 1234},
wantErr: false,
},
{
name: "invalid config type",
config: reflect.ValueOf("not a config"),
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &Generator{}
got, err := g.validateAndReturnConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateAndReturnConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("validateAndReturnConfig() = %v, want %v", got, tt.want)
}
})
}
}
// atoiOrZero converts a string to an int, returning 0 on error.
func atoiOrZero(s string) int {
i, _ := strconv.Atoi(s)
return i
}

44
pkg/generators/router.go Normal file
View file

@ -0,0 +1,44 @@
package generators
import (
"errors"
"fmt"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/generators/basic"
"github.com/nicholas-fedor/shoutrrr/pkg/generators/xouath2"
"github.com/nicholas-fedor/shoutrrr/pkg/services/telegram"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var ErrUnknownGenerator = errors.New("unknown generator")
var generatorMap = map[string]func() types.Generator{
"basic": func() types.Generator { return &basic.Generator{} },
"oauth2": func() types.Generator { return &xouath2.Generator{} },
"telegram": func() types.Generator { return &telegram.Generator{} },
}
// NewGenerator creates an instance of the generator that corresponds to the provided identifier.
func NewGenerator(identifier string) (types.Generator, error) {
generatorFactory, valid := generatorMap[strings.ToLower(identifier)]
if !valid {
return nil, fmt.Errorf("%w: %q", ErrUnknownGenerator, identifier)
}
return generatorFactory(), nil
}
// ListGenerators lists all available generators.
func ListGenerators() []string {
generators := make([]string, len(generatorMap))
i := 0
for key := range generatorMap {
generators[i] = key
i++
}
return generators
}

View file

@ -0,0 +1,266 @@
//go:generate stringer -type=URLPart -trimprefix URL
package xouath2
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/nicholas-fedor/shoutrrr/pkg/services/smtp"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// SMTP port constants.
const (
DefaultSMTPPort uint16 = 25 // Standard SMTP port without encryption
GmailSMTPPortStartTLS uint16 = 587 // Gmail SMTP port with STARTTLS
)
const StateLength int = 16 // Length in bytes for OAuth 2.0 state randomness (128 bits)
// Errors.
var (
ErrReadFileFailed = errors.New("failed to read file")
ErrUnmarshalFailed = errors.New("failed to unmarshal JSON")
ErrScanFailed = errors.New("failed to scan input")
ErrTokenExchangeFailed = errors.New("failed to exchange token")
)
// Generator is the XOAuth2 Generator implementation.
type Generator struct{}
// Generate generates a service URL from a set of user questions/answers.
func (g *Generator) Generate(
_ types.Service,
props map[string]string,
args []string,
) (types.ServiceConfig, error) {
if provider, found := props["provider"]; found {
if provider == "gmail" {
return oauth2GeneratorGmail(args[0])
}
}
if len(args) > 0 {
return oauth2GeneratorFile(args[0])
}
return oauth2Generator()
}
func oauth2GeneratorFile(file string) (*smtp.Config, error) {
jsonData, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("%s: %w", file, ErrReadFileFailed)
}
var providerConfig struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"`
Hostname string `json:"smtp_hostname"`
Scopes []string `json:"scopes"`
}
if err := json.Unmarshal(jsonData, &providerConfig); err != nil {
return nil, fmt.Errorf("%s: %w", file, ErrUnmarshalFailed)
}
conf := oauth2.Config{
ClientID: providerConfig.ClientID,
ClientSecret: providerConfig.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: providerConfig.AuthURL,
TokenURL: providerConfig.TokenURL,
AuthStyle: oauth2.AuthStyleAutoDetect,
},
RedirectURL: providerConfig.RedirectURL,
Scopes: providerConfig.Scopes,
}
return generateOauth2Config(&conf, providerConfig.Hostname)
}
func oauth2Generator() (*smtp.Config, error) {
scanner := bufio.NewScanner(os.Stdin)
var clientID string
fmt.Fprint(os.Stdout, "ClientID: ")
if scanner.Scan() {
clientID = scanner.Text()
} else {
return nil, fmt.Errorf("clientID: %w", ErrScanFailed)
}
var clientSecret string
fmt.Fprint(os.Stdout, "ClientSecret: ")
if scanner.Scan() {
clientSecret = scanner.Text()
} else {
return nil, fmt.Errorf("clientSecret: %w", ErrScanFailed)
}
var authURL string
fmt.Fprint(os.Stdout, "AuthURL: ")
if scanner.Scan() {
authURL = scanner.Text()
} else {
return nil, fmt.Errorf("authURL: %w", ErrScanFailed)
}
var tokenURL string
fmt.Fprint(os.Stdout, "TokenURL: ")
if scanner.Scan() {
tokenURL = scanner.Text()
} else {
return nil, fmt.Errorf("tokenURL: %w", ErrScanFailed)
}
var redirectURL string
fmt.Fprint(os.Stdout, "RedirectURL: ")
if scanner.Scan() {
redirectURL = scanner.Text()
} else {
return nil, fmt.Errorf("redirectURL: %w", ErrScanFailed)
}
var scopes string
fmt.Fprint(os.Stdout, "Scopes: ")
if scanner.Scan() {
scopes = scanner.Text()
} else {
return nil, fmt.Errorf("scopes: %w", ErrScanFailed)
}
var hostname string
fmt.Fprint(os.Stdout, "SMTP Hostname: ")
if scanner.Scan() {
hostname = scanner.Text()
} else {
return nil, fmt.Errorf("hostname: %w", ErrScanFailed)
}
conf := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
AuthStyle: oauth2.AuthStyleAutoDetect,
},
RedirectURL: redirectURL,
Scopes: strings.Split(scopes, ","),
}
return generateOauth2Config(&conf, hostname)
}
func oauth2GeneratorGmail(credFile string) (*smtp.Config, error) {
data, err := os.ReadFile(credFile)
if err != nil {
return nil, fmt.Errorf("%s: %w", credFile, ErrReadFileFailed)
}
conf, err := google.ConfigFromJSON(data, "https://mail.google.com/")
if err != nil {
return nil, fmt.Errorf(
"%s: %w",
credFile,
err,
) // google.ConfigFromJSON error doesn't need custom wrapping
}
return generateOauth2Config(conf, "smtp.gmail.com")
}
func generateOauth2Config(conf *oauth2.Config, host string) (*smtp.Config, error) {
scanner := bufio.NewScanner(os.Stdin)
// Generate a random state value
stateBytes := make([]byte, StateLength)
if _, err := rand.Read(stateBytes); err != nil {
return nil, fmt.Errorf("generating random state: %w", err)
}
state := base64.URLEncoding.EncodeToString(stateBytes)
fmt.Fprintf(
os.Stdout,
"Visit the following URL to authenticate:\n%s\n\n",
conf.AuthCodeURL(state),
)
var verCode string
fmt.Fprint(os.Stdout, "Enter verification code: ")
if scanner.Scan() {
verCode = scanner.Text()
} else {
return nil, fmt.Errorf("verification code: %w", ErrScanFailed)
}
ctx := context.Background()
token, err := conf.Exchange(ctx, verCode)
if err != nil {
return nil, fmt.Errorf("%s: %w", verCode, ErrTokenExchangeFailed)
}
var sender string
fmt.Fprint(os.Stdout, "Enter sender e-mail: ")
if scanner.Scan() {
sender = scanner.Text()
} else {
return nil, fmt.Errorf("sender email: %w", ErrScanFailed)
}
// Determine the appropriate port based on the host
port := DefaultSMTPPort
if host == "smtp.gmail.com" {
port = GmailSMTPPortStartTLS // Use 587 for Gmail with STARTTLS
}
svcConf := &smtp.Config{
Host: host,
Port: port,
Username: sender,
Password: token.AccessToken,
FromAddress: sender,
FromName: "Shoutrrr",
ToAddresses: []string{sender},
Auth: smtp.AuthTypes.OAuth2,
UseStartTLS: true,
UseHTML: true,
}
return svcConf, nil
}

279
pkg/router/router.go Normal file
View file

@ -0,0 +1,279 @@
package router
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// DefaultTimeout is the default duration for service operation timeouts.
const DefaultTimeout = 10 * time.Second
var (
ErrNoSenders = errors.New("error sending message: no senders")
ErrServiceTimeout = errors.New("failed to send: timed out")
ErrCustomURLsNotSupported = errors.New("custom URLs are not supported by service")
ErrUnknownService = errors.New("unknown service")
ErrParseURLFailed = errors.New("failed to parse URL")
ErrSendFailed = errors.New("failed to send message")
ErrCustomURLConversion = errors.New("failed to convert custom URL")
ErrInitializeFailed = errors.New("failed to initialize service")
)
// ServiceRouter is responsible for routing a message to a specific notification service using the notification URL.
type ServiceRouter struct {
logger types.StdLogger
services []types.Service
queue []string
Timeout time.Duration
}
// New creates a new service router using the specified logger and service URLs.
func New(logger types.StdLogger, serviceURLs ...string) (*ServiceRouter, error) {
router := ServiceRouter{
logger: logger,
Timeout: DefaultTimeout,
}
for _, serviceURL := range serviceURLs {
if err := router.AddService(serviceURL); err != nil {
return nil, fmt.Errorf("error initializing router services: %w", err)
}
}
return &router, nil
}
// AddService initializes the specified service from its URL, and adds it if no errors occur.
func (router *ServiceRouter) AddService(serviceURL string) error {
service, err := router.initService(serviceURL)
if err == nil {
router.services = append(router.services, service)
}
return err
}
// Send sends the specified message using the routers underlying services.
func (router *ServiceRouter) Send(message string, params *types.Params) []error {
if router == nil {
return []error{ErrNoSenders}
}
serviceCount := len(router.services)
errors := make([]error, serviceCount)
results := router.SendAsync(message, params)
for i := range router.services {
errors[i] = <-results
}
return errors
}
// SendItems sends the specified message items using the routers underlying services.
func (router *ServiceRouter) SendItems(items []types.MessageItem, params types.Params) []error {
if router == nil {
return []error{ErrNoSenders}
}
// Fallback using old API for now
message := strings.Builder{}
for _, item := range items {
message.WriteString(item.Text)
}
serviceCount := len(router.services)
errors := make([]error, serviceCount)
results := router.SendAsync(message.String(), &params)
for i := range router.services {
errors[i] = <-results
}
return errors
}
// SendAsync sends the specified message using the routers underlying services.
func (router *ServiceRouter) SendAsync(message string, params *types.Params) chan error {
serviceCount := len(router.services)
proxy := make(chan error, serviceCount)
errors := make(chan error, serviceCount)
if params == nil {
params = &types.Params{}
}
for _, service := range router.services {
go sendToService(service, proxy, router.Timeout, message, *params)
}
go func() {
for range serviceCount {
errors <- <-proxy
}
close(errors)
}()
return errors
}
func sendToService(
service types.Service,
results chan error,
timeout time.Duration,
message string,
params types.Params,
) {
result := make(chan error)
serviceID := service.GetID()
go func() { result <- service.Send(message, &params) }()
select {
case res := <-result:
results <- res
case <-time.After(timeout):
results <- fmt.Errorf("%w: using %v", ErrServiceTimeout, serviceID)
}
}
// Enqueue adds the message to an internal queue and sends it when Flush is invoked.
func (router *ServiceRouter) Enqueue(message string, v ...any) {
if len(v) > 0 {
message = fmt.Sprintf(message, v...)
}
router.queue = append(router.queue, message)
}
// Flush sends all messages that have been queued up as a combined message. This method should be deferred!
func (router *ServiceRouter) Flush(params *types.Params) {
// Since this method is supposed to be deferred we just have to ignore errors
_ = router.Send(strings.Join(router.queue, "\n"), params)
router.queue = []string{}
}
// SetLogger sets the logger that the services will use to write progress logs.
func (router *ServiceRouter) SetLogger(logger types.StdLogger) {
router.logger = logger
for _, service := range router.services {
service.SetLogger(logger)
}
}
// ExtractServiceName from a notification URL.
func (router *ServiceRouter) ExtractServiceName(rawURL string) (string, *url.URL, error) {
serviceURL, err := url.Parse(rawURL)
if err != nil {
return "", &url.URL{}, fmt.Errorf("%s: %w", rawURL, ErrParseURLFailed)
}
scheme := serviceURL.Scheme
schemeParts := strings.Split(scheme, "+")
if len(schemeParts) > 1 {
scheme = schemeParts[0]
}
return scheme, serviceURL, nil
}
// Route a message to a specific notification service using the notification URL.
func (router *ServiceRouter) Route(rawURL string, message string) error {
service, err := router.Locate(rawURL)
if err != nil {
return err
}
if err := service.Send(message, nil); err != nil {
return fmt.Errorf("%s: %w", service.GetID(), ErrSendFailed)
}
return nil
}
func (router *ServiceRouter) initService(rawURL string) (types.Service, error) {
scheme, configURL, err := router.ExtractServiceName(rawURL)
if err != nil {
return nil, err
}
service, err := newService(scheme)
if err != nil {
return nil, err
}
if configURL.Scheme != scheme {
router.log("Got custom URL:", configURL.String())
customURLService, ok := service.(types.CustomURLService)
if !ok {
return nil, fmt.Errorf("%w: '%s' service", ErrCustomURLsNotSupported, scheme)
}
configURL, err = customURLService.GetConfigURLFromCustom(configURL)
if err != nil {
return nil, fmt.Errorf("%s: %w", configURL.String(), ErrCustomURLConversion)
}
router.log("Converted service URL:", configURL.String())
}
err = service.Initialize(configURL, router.logger)
if err != nil {
return service, fmt.Errorf("%s: %w", scheme, ErrInitializeFailed)
}
return service, nil
}
// NewService returns a new uninitialized service instance.
func (*ServiceRouter) NewService(serviceScheme string) (types.Service, error) {
return newService(serviceScheme)
}
// newService returns a new uninitialized service instance.
func newService(serviceScheme string) (types.Service, error) {
serviceFactory, valid := serviceMap[strings.ToLower(serviceScheme)]
if !valid {
return nil, fmt.Errorf("%w: %q", ErrUnknownService, serviceScheme)
}
return serviceFactory(), nil
}
// ListServices returns the available services.
func (router *ServiceRouter) ListServices() []string {
services := make([]string, len(serviceMap))
i := 0
for key := range serviceMap {
services[i] = key
i++
}
return services
}
// Locate returns the service implementation that corresponds to the given service URL.
func (router *ServiceRouter) Locate(rawURL string) (types.Service, error) {
service, err := router.initService(rawURL)
return service, err
}
func (router *ServiceRouter) log(v ...any) {
if router.logger == nil {
return
}
router.logger.Println(v...)
}

View file

@ -0,0 +1,157 @@
package router
import (
"fmt"
"log"
"os"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
func TestRouter(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Router Suite")
}
var sr ServiceRouter
const (
mockCustomURL = "teams+https://publicservice.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=publicservice.webhook.office.com"
)
var _ = ginkgo.Describe("the router suite", func() {
ginkgo.BeforeEach(func() {
sr = ServiceRouter{
logger: log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags),
}
})
ginkgo.When("extract service name is given a url", func() {
ginkgo.It("should extract the protocol/service part", func() {
url := "slack://rest/of/url"
serviceName, _, err := sr.ExtractServiceName(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(serviceName).To(gomega.Equal("slack"))
})
ginkgo.It("should extract the service part when provided in custom form", func() {
url := "teams+https://rest/of/url"
serviceName, _, err := sr.ExtractServiceName(url)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(serviceName).To(gomega.Equal("teams"))
})
ginkgo.It("should return an error if the protocol/service part is missing", func() {
url := "://rest/of/url"
serviceName, _, err := sr.ExtractServiceName(url)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(serviceName).To(gomega.Equal(""))
})
ginkgo.It(
"should return an error if the protocol/service part is containing invalid letters",
func() {
url := "a d://rest/of/url"
serviceName, _, err := sr.ExtractServiceName(url)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(serviceName).To(gomega.Equal(""))
},
)
})
ginkgo.When("initializing a service with a custom URL", func() {
ginkgo.It("should return an error if the service does not support it", func() {
service, err := sr.initService("log+https://hybr.is")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(service).To(gomega.BeNil())
})
})
ginkgo.Describe("the service map", func() {
ginkgo.When("resolving implemented services", func() {
services := (&ServiceRouter{}).ListServices()
for _, scheme := range services {
// copy ref to local closure
serviceScheme := scheme
ginkgo.It(fmt.Sprintf("should return a Service for '%s'", serviceScheme), func() {
service, err := newService(serviceScheme)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service).ToNot(gomega.BeNil())
})
}
})
})
ginkgo.When("initializing a service with a custom URL", func() {
ginkgo.It("should return an error if the service does not support it", func() {
service, err := sr.initService("log+https://hybr.is")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(service).To(gomega.BeNil())
})
ginkgo.It("should successfully init a service that does support it", func() {
service, err := sr.initService(mockCustomURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service).NotTo(gomega.BeNil())
})
})
ginkgo.When("a message is enqueued", func() {
ginkgo.It("should be added to the internal queue", func() {
sr.Enqueue("message body")
gomega.Expect(sr.queue).ToNot(gomega.BeNil())
gomega.Expect(sr.queue).To(gomega.HaveLen(1))
})
})
ginkgo.When("a formatted message is enqueued", func() {
ginkgo.It("should be added with the specified format", func() {
sr.Enqueue("message with number %d", 5)
gomega.Expect(sr.queue).ToNot(gomega.BeNil())
gomega.Expect(sr.queue[0]).To(gomega.Equal("message with number 5"))
})
})
ginkgo.When("it leaves the scope after flush has been deferred", func() {
ginkgo.When("it hasn't been assigned a sender", func() {
ginkgo.It("should not cause a panic", func() {
defer sr.Flush(nil)
sr.Enqueue("message")
})
})
})
ginkgo.When("router has not been provided a logger", func() {
ginkgo.It("should not crash when trying to log", func() {
router := ServiceRouter{}
_, err := router.initService(mockCustomURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
})
func ExampleNew() {
logger := log.New(os.Stdout, "", 0)
sr, err := New(logger, "logger://")
if err != nil {
log.Fatalf("could not create router: %s", err)
}
sr.Send("hello", nil)
// Output: hello
}
func ExampleServiceRouter_Enqueue() {
logger := log.New(os.Stdout, "", 0)
sr, err := New(logger, "logger://")
if err != nil {
log.Fatalf("could not create router: %s", err)
}
defer sr.Flush(nil)
sr.Enqueue("hello")
sr.Enqueue("world")
// Output:
// hello
// world
}

51
pkg/router/servicemap.go Normal file
View file

@ -0,0 +1,51 @@
package router
import (
"github.com/nicholas-fedor/shoutrrr/pkg/services/bark"
"github.com/nicholas-fedor/shoutrrr/pkg/services/discord"
"github.com/nicholas-fedor/shoutrrr/pkg/services/generic"
"github.com/nicholas-fedor/shoutrrr/pkg/services/googlechat"
"github.com/nicholas-fedor/shoutrrr/pkg/services/gotify"
"github.com/nicholas-fedor/shoutrrr/pkg/services/ifttt"
"github.com/nicholas-fedor/shoutrrr/pkg/services/join"
"github.com/nicholas-fedor/shoutrrr/pkg/services/lark"
"github.com/nicholas-fedor/shoutrrr/pkg/services/logger"
"github.com/nicholas-fedor/shoutrrr/pkg/services/matrix"
"github.com/nicholas-fedor/shoutrrr/pkg/services/mattermost"
"github.com/nicholas-fedor/shoutrrr/pkg/services/ntfy"
"github.com/nicholas-fedor/shoutrrr/pkg/services/opsgenie"
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet"
"github.com/nicholas-fedor/shoutrrr/pkg/services/pushover"
"github.com/nicholas-fedor/shoutrrr/pkg/services/rocketchat"
"github.com/nicholas-fedor/shoutrrr/pkg/services/slack"
"github.com/nicholas-fedor/shoutrrr/pkg/services/smtp"
"github.com/nicholas-fedor/shoutrrr/pkg/services/teams"
"github.com/nicholas-fedor/shoutrrr/pkg/services/telegram"
"github.com/nicholas-fedor/shoutrrr/pkg/services/zulip"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var serviceMap = map[string]func() types.Service{
"bark": func() types.Service { return &bark.Service{} },
"discord": func() types.Service { return &discord.Service{} },
"generic": func() types.Service { return &generic.Service{} },
"gotify": func() types.Service { return &gotify.Service{} },
"googlechat": func() types.Service { return &googlechat.Service{} },
"hangouts": func() types.Service { return &googlechat.Service{} },
"ifttt": func() types.Service { return &ifttt.Service{} },
"lark": func() types.Service { return &lark.Service{} },
"join": func() types.Service { return &join.Service{} },
"logger": func() types.Service { return &logger.Service{} },
"matrix": func() types.Service { return &matrix.Service{} },
"mattermost": func() types.Service { return &mattermost.Service{} },
"ntfy": func() types.Service { return &ntfy.Service{} },
"opsgenie": func() types.Service { return &opsgenie.Service{} },
"pushbullet": func() types.Service { return &pushbullet.Service{} },
"pushover": func() types.Service { return &pushover.Service{} },
"rocketchat": func() types.Service { return &rocketchat.Service{} },
"slack": func() types.Service { return &slack.Service{} },
"smtp": func() types.Service { return &smtp.Service{} },
"teams": func() types.Service { return &teams.Service{} },
"telegram": func() types.Service { return &telegram.Service{} },
"zulip": func() types.Service { return &zulip.Service{} },
}

View file

@ -0,0 +1,10 @@
//go:build xmpp
// +build xmpp
package router
import t "github.com/nicholas-fedor/shoutrrr/pkg/types"
func init() {
serviceMap["xmpp"] = func() t.Service { return &xmpp.Service{} }
}

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
}

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