Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
49
pkg/format/config_props.go
Normal file
49
pkg/format/config_props.go
Normal 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
|
||||
}
|
71
pkg/format/enum_formatter.go
Normal file
71
pkg/format/enum_formatter.go
Normal 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
134
pkg/format/field_info.go
Normal 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
34
pkg/format/format.go
Normal 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
|
||||
}
|
80
pkg/format/format_colorize.go
Normal file
80
pkg/format/format_colorize.go
Normal 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
|
||||
}
|
95
pkg/format/format_query.go
Normal file
95
pkg/format/format_query.go
Normal 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 = "__"
|
50
pkg/format/format_query_test.go
Normal file
50
pkg/format/format_query_test.go
Normal 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
157
pkg/format/format_test.go
Normal 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
509
pkg/format/formatter.go
Normal 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
|
||||
}
|
288
pkg/format/formatter_test.go
Normal file
288
pkg/format/formatter_test.go
Normal 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
390
pkg/format/node.go
Normal 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()
|
||||
}
|
176
pkg/format/prop_key_resolver.go
Normal file
176
pkg/format/prop_key_resolver.go
Normal 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
|
||||
}
|
58
pkg/format/prop_key_resolver_test.go
Normal file
58
pkg/format/prop_key_resolver_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
201
pkg/format/render_console.go
Normal file
201
pkg/format/render_console.go
Normal 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
|
||||
}
|
54
pkg/format/render_console_test.go
Normal file
54
pkg/format/render_console_test.go
Normal 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))
|
||||
})
|
||||
})
|
259
pkg/format/render_markdown.go
Normal file
259
pkg/format/render_markdown.go
Normal 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")
|
||||
}
|
149
pkg/format/render_markdown_test.go
Normal file
149
pkg/format/render_markdown_test.go
Normal 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
17
pkg/format/render_test.go
Normal 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")
|
||||
}
|
6
pkg/format/tree_renderer.go
Normal file
6
pkg/format/tree_renderer.go
Normal 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
84
pkg/format/urlpart.go
Normal 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
|
||||
}
|
28
pkg/format/urlpart_string.go
Normal file
28
pkg/format/urlpart_string.go
Normal 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]]
|
||||
}
|
32
pkg/format/urlpart_test.go
Normal file
32
pkg/format/urlpart_test.go
Normal 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('/'))
|
||||
})
|
||||
})
|
219
pkg/generators/basic/basic.go
Normal file
219
pkg/generators/basic/basic.go
Normal 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 field’s 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 field’s 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
|
||||
}
|
543
pkg/generators/basic/basic_test.go
Normal file
543
pkg/generators/basic/basic_test.go
Normal 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
44
pkg/generators/router.go
Normal 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
|
||||
}
|
266
pkg/generators/xouath2/xoauth2.go
Normal file
266
pkg/generators/xouath2/xoauth2.go
Normal 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
279
pkg/router/router.go
Normal 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(), ¶ms)
|
||||
|
||||
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, ¶ms) }()
|
||||
|
||||
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...)
|
||||
}
|
157
pkg/router/router_suite_test.go
Normal file
157
pkg/router/router_suite_test.go
Normal 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
51
pkg/router/servicemap.go
Normal 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{} },
|
||||
}
|
10
pkg/router/servicemap_xmpp.go
Normal file
10
pkg/router/servicemap_xmpp.go
Normal 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
92
pkg/services/bark/bark.go
Normal 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
|
||||
}
|
101
pkg/services/bark/bark_config.go
Normal file
101
pkg/services/bark/bark_config.go
Normal 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
|
||||
}
|
29
pkg/services/bark/bark_json.go
Normal file
29
pkg/services/bark/bark_json.go
Normal 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
|
||||
}
|
181
pkg/services/bark/bark_test.go
Normal file
181
pkg/services/bark/bark_test.go
Normal 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")
|
||||
}
|
214
pkg/services/discord/discord.go
Normal file
214
pkg/services/discord/discord.go
Normal 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
|
||||
}
|
121
pkg/services/discord/discord_config.go
Normal file
121
pkg/services/discord/discord_config.go
Normal 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
|
||||
}
|
86
pkg/services/discord/discord_json.go
Normal file
86
pkg/services/discord/discord_json.go
Normal 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
|
||||
}
|
247
pkg/services/discord/discord_playground.http
Normal file
247
pkg/services/discord/discord_playground.http
Normal file
File diff suppressed because one or more lines are too long
332
pkg/services/discord/discord_test.go
Normal file
332
pkg/services/discord/discord_test.go
Normal 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, ""))
|
||||
}
|
74
pkg/services/generic/custom_query.go
Normal file
74
pkg/services/generic/custom_query.go
Normal 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
|
||||
}
|
181
pkg/services/generic/generic.go
Normal file
181
pkg/services/generic/generic.go
Normal 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, ¶ms); 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
|
||||
}
|
123
pkg/services/generic/generic_config.go
Normal file
123
pkg/services/generic/generic_config.go
Normal 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
|
||||
}
|
359
pkg/services/generic/generic_test.go
Normal file
359
pkg/services/generic/generic_test.go
Normal 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", ¶ms)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(params).To(gomega.Equal(types.Params{"title": "TITLE"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
87
pkg/services/googlechat/googlechat.go
Normal file
87
pkg/services/googlechat/googlechat.go
Normal 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(),
|
||||
}
|
||||
}
|
73
pkg/services/googlechat/googlechat_config.go
Normal file
73
pkg/services/googlechat/googlechat_config.go
Normal 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,
|
||||
}
|
||||
}
|
6
pkg/services/googlechat/googlechat_json.go
Normal file
6
pkg/services/googlechat/googlechat_json.go
Normal 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"`
|
||||
}
|
220
pkg/services/googlechat/googlechat_test.go
Normal file
220
pkg/services/googlechat/googlechat_test.go
Normal 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",
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
148
pkg/services/gotify/gotify.go
Normal file
148
pkg/services/gotify/gotify.go
Normal 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
|
||||
}
|
76
pkg/services/gotify/gotify_config.go
Normal file
76
pkg/services/gotify/gotify_config.go
Normal 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
|
||||
}
|
27
pkg/services/gotify/gotify_json.go
Normal file
27
pkg/services/gotify/gotify_json.go
Normal 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)
|
||||
}
|
246
pkg/services/gotify/gotify_test.go
Normal file
246
pkg/services/gotify/gotify_test.go
Normal 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", ¶ms)
|
||||
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
106
pkg/services/ifttt/ifttt.go
Normal 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
|
||||
}
|
107
pkg/services/ifttt/ifttt_config.go
Normal file
107
pkg/services/ifttt/ifttt_config.go
Normal 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
|
||||
}
|
61
pkg/services/ifttt/ifttt_json.go
Normal file
61
pkg/services/ifttt/ifttt_json.go
Normal 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
|
||||
}
|
335
pkg/services/ifttt/ifttt_test.go
Normal file
335
pkg/services/ifttt/ifttt_test.go
Normal 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", ¶ms)
|
||||
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", ¶ms)
|
||||
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", ¶ms)
|
||||
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
119
pkg/services/join/join.go
Normal 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
|
||||
}
|
79
pkg/services/join/join_config.go
Normal file
79
pkg/services/join/join_config.go
Normal 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
|
||||
}
|
12
pkg/services/join/join_errors.go
Normal file
12
pkg/services/join/join_errors.go
Normal 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"
|
||||
)
|
173
pkg/services/join/join_test.go
Normal file
173
pkg/services/join/join_test.go
Normal 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)))
|
||||
}
|
74
pkg/services/lark/lark_config.go
Normal file
74
pkg/services/lark/lark_config.go
Normal 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
|
||||
}
|
59
pkg/services/lark/lark_message.go
Normal file
59
pkg/services/lark/lark_message.go
Normal 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"`
|
||||
}
|
237
pkg/services/lark/lark_service.go
Normal file
237
pkg/services/lark/lark_service.go
Normal 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
|
||||
}
|
215
pkg/services/lark/lark_test.go
Normal file
215
pkg/services/lark/lark_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
61
pkg/services/logger/logger.go
Normal file
61
pkg/services/logger/logger.go
Normal 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
|
||||
}
|
30
pkg/services/logger/logger_config.go
Normal file
30
pkg/services/logger/logger_config.go
Normal 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
|
||||
}
|
107
pkg/services/logger/logger_suite_test.go
Normal file
107
pkg/services/logger/logger_suite_test.go
Normal 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`, ¶ms)
|
||||
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`, ¶ms)
|
||||
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`, ¶ms)
|
||||
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"))
|
||||
})
|
||||
})
|
||||
})
|
79
pkg/services/matrix/matrix.go
Normal file
79
pkg/services/matrix/matrix.go
Normal 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
|
||||
}
|
82
pkg/services/matrix/matrix_api.go
Normal file
82
pkg/services/matrix/matrix_api.go
Normal 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,
|
||||
}
|
||||
}
|
316
pkg/services/matrix/matrix_client.go
Normal file
316
pkg/services/matrix/matrix_client.go
Normal 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
|
||||
}
|
68
pkg/services/matrix/matrix_config.go
Normal file
68
pkg/services/matrix/matrix_config.go
Normal 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
|
||||
}
|
676
pkg/services/matrix/matrix_test.go
Normal file
676
pkg/services/matrix/matrix_test.go
Normal 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")))
|
||||
}
|
116
pkg/services/mattermost/mattermost.go
Normal file
116
pkg/services/mattermost/mattermost.go
Normal 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)
|
||||
}
|
121
pkg/services/mattermost/mattermost_config.go
Normal file
121
pkg/services/mattermost/mattermost_config.go
Normal 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
|
||||
}
|
63
pkg/services/mattermost/mattermost_json.go
Normal file
63
pkg/services/mattermost/mattermost_json.go
Normal 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
|
||||
}
|
440
pkg/services/mattermost/mattermost_test.go
Normal file
440
pkg/services/mattermost/mattermost_test.go
Normal 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
99
pkg/services/ntfy/ntfy.go
Normal 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)
|
||||
}
|
||||
}
|
119
pkg/services/ntfy/ntfy_config.go
Normal file
119
pkg/services/ntfy/ntfy_config.go
Normal 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
|
||||
}
|
19
pkg/services/ntfy/ntfy_json.go
Normal file
19
pkg/services/ntfy/ntfy_json.go
Normal 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
|
||||
}
|
55
pkg/services/ntfy/ntfy_priority.go
Normal file
55
pkg/services/ntfy/ntfy_priority.go
Normal 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))
|
||||
}
|
162
pkg/services/ntfy/ntfy_test.go
Normal file
162
pkg/services/ntfy/ntfy_test.go
Normal 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"))
|
||||
})
|
||||
})
|
160
pkg/services/opsgenie/opsgenie.go
Normal file
160
pkg/services/opsgenie/opsgenie.go
Normal 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
|
||||
}
|
109
pkg/services/opsgenie/opsgenie_config.go
Normal file
109
pkg/services/opsgenie/opsgenie_config.go
Normal 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
|
||||
}
|
93
pkg/services/opsgenie/opsgenie_entity.go
Normal file
93
pkg/services/opsgenie/opsgenie_entity.go
Normal 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
|
||||
}
|
33
pkg/services/opsgenie/opsgenie_json.go
Normal file
33
pkg/services/opsgenie/opsgenie_json.go
Normal 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"`
|
||||
}
|
422
pkg/services/opsgenie/opsgenie_test.go
Normal file
422
pkg/services/opsgenie/opsgenie_test.go
Normal 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¬e=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¬e=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¬e=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`))
|
||||
})
|
||||
})
|
||||
})
|
118
pkg/services/pushbullet/pushbullet.go
Normal file
118
pkg/services/pushbullet/pushbullet.go
Normal 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
|
||||
}
|
95
pkg/services/pushbullet/pushbullet_config.go
Normal file
95
pkg/services/pushbullet/pushbullet_config.go
Normal 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
|
||||
}
|
74
pkg/services/pushbullet/pushbullet_json.go
Normal file
74
pkg/services/pushbullet/pushbullet_json.go
Normal 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,
|
||||
}
|
||||
}
|
248
pkg/services/pushbullet/pushbullet_test.go
Normal file
248
pkg/services/pushbullet/pushbullet_test.go
Normal 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())
|
||||
}
|
114
pkg/services/pushover/pushover.go
Normal file
114
pkg/services/pushover/pushover.go
Normal 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
|
||||
}
|
83
pkg/services/pushover/pushover_config.go
Normal file
83
pkg/services/pushover/pushover_config.go
Normal 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),
|
||||
}
|
||||
}
|
11
pkg/services/pushover/pushover_error.go
Normal file
11
pkg/services/pushover/pushover_error.go
Normal 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"
|
||||
)
|
197
pkg/services/pushover/pushover_test.go
Normal file
197
pkg/services/pushover/pushover_test.go
Normal 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)))
|
||||
}
|
103
pkg/services/rocketchat/rocketchat.go
Normal file
103
pkg/services/rocketchat/rocketchat.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// defaultHTTPTimeout is the default timeout for HTTP requests.
|
||||
const defaultHTTPTimeout = 10 * time.Second
|
||||
|
||||
// ErrNotificationFailed indicates a failure in sending the notification.
|
||||
var ErrNotificationFailed = errors.New("notification failed")
|
||||
|
||||
// Service sends notifications to a pre-configured Rocket.Chat channel or user.
|
||||
type Service struct {
|
||||
standard.Standard
|
||||
Config *Config
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// Initialize configures the service with a URL and logger.
|
||||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
|
||||
service.SetLogger(logger)
|
||||
|
||||
service.Config = &Config{}
|
||||
if service.Client == nil {
|
||||
service.Client = &http.Client{
|
||||
Timeout: defaultHTTPTimeout, // Set a default timeout
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.Config.SetURL(configURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetID returns the service identifier.
|
||||
func (service *Service) GetID() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
// Send delivers a notification message to Rocket.Chat.
|
||||
func (service *Service) Send(message string, params *types.Params) error {
|
||||
var res *http.Response
|
||||
|
||||
var err error
|
||||
|
||||
config := service.Config
|
||||
apiURL := buildURL(config)
|
||||
json, _ := CreateJSONPayload(config, message, params)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(json))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err = service.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"posting to URL: %w\nHOST: %s\nPORT: %s",
|
||||
err,
|
||||
config.Host,
|
||||
config.Port,
|
||||
)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
resBody, _ := io.ReadAll(res.Body)
|
||||
|
||||
return fmt.Errorf("%w: %d %s", ErrNotificationFailed, res.StatusCode, resBody)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildURL constructs the API URL for Rocket.Chat based on the Config.
|
||||
func buildURL(config *Config) string {
|
||||
base := config.Host
|
||||
if config.Port != "" {
|
||||
base = net.JoinHostPort(config.Host, config.Port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s/hooks/%s/%s", base, config.TokenA, config.TokenB)
|
||||
}
|
91
pkg/services/rocketchat/rocketchat_config.go
Normal file
91
pkg/services/rocketchat/rocketchat_config.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
)
|
||||
|
||||
// Scheme is the identifying part of this service's configuration URL.
|
||||
const Scheme = "rocketchat"
|
||||
|
||||
// Constants for URL path length checks.
|
||||
const (
|
||||
MinPathParts = 3 // Minimum number of path parts required (including empty first slash)
|
||||
TokenBIndex = 2 // Index for TokenB in path
|
||||
ChannelIndex = 3 // Index for Channel in path
|
||||
)
|
||||
|
||||
// Static errors for configuration validation.
|
||||
var (
|
||||
ErrNotEnoughArguments = errors.New("the apiURL does not include enough arguments")
|
||||
)
|
||||
|
||||
// Config for the Rocket.Chat service.
|
||||
type Config struct {
|
||||
standard.EnumlessConfig
|
||||
UserName string `optional:"" url:"user"`
|
||||
Host string ` url:"host"`
|
||||
Port string ` url:"port"`
|
||||
TokenA string ` url:"path1"`
|
||||
Channel string ` url:"path3"`
|
||||
TokenB string ` url:"path2"`
|
||||
}
|
||||
|
||||
// GetURL returns a URL representation of the Config's current field values.
|
||||
func (config *Config) GetURL() *url.URL {
|
||||
host := config.Host
|
||||
if config.Port != "" {
|
||||
host = fmt.Sprintf("%s:%s", config.Host, config.Port)
|
||||
}
|
||||
|
||||
url := &url.URL{
|
||||
Host: host,
|
||||
Path: fmt.Sprintf("%s/%s", config.TokenA, config.TokenB),
|
||||
Scheme: Scheme,
|
||||
ForceQuery: false,
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// SetURL updates the Config from a URL representation of its field values.
|
||||
func (config *Config) SetURL(serviceURL *url.URL) error {
|
||||
userName := serviceURL.User.Username()
|
||||
host := serviceURL.Hostname()
|
||||
|
||||
path := strings.Split(serviceURL.Path, "/")
|
||||
if serviceURL.String() != "rocketchat://dummy@dummy.com" {
|
||||
if len(path) < MinPathParts {
|
||||
return ErrNotEnoughArguments
|
||||
}
|
||||
}
|
||||
|
||||
config.Port = serviceURL.Port()
|
||||
config.UserName = userName
|
||||
config.Host = host
|
||||
|
||||
if len(path) > 1 {
|
||||
config.TokenA = path[1]
|
||||
}
|
||||
|
||||
if len(path) > TokenBIndex {
|
||||
config.TokenB = path[TokenBIndex]
|
||||
}
|
||||
|
||||
if len(path) > ChannelIndex {
|
||||
switch {
|
||||
case serviceURL.Fragment != "":
|
||||
config.Channel = "#" + serviceURL.Fragment
|
||||
case !strings.HasPrefix(path[ChannelIndex], "@"):
|
||||
config.Channel = "#" + path[ChannelIndex]
|
||||
default:
|
||||
config.Channel = path[ChannelIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
41
pkg/services/rocketchat/rocketchat_json.go
Normal file
41
pkg/services/rocketchat/rocketchat_json.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// JSON represents the payload structure for the Rocket.Chat service.
|
||||
type JSON struct {
|
||||
Text string `json:"text"`
|
||||
UserName string `json:"username,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// CreateJSONPayload generates a JSON payload compatible with the Rocket.Chat webhook API.
|
||||
func CreateJSONPayload(config *Config, message string, params *types.Params) ([]byte, error) {
|
||||
payload := JSON{
|
||||
Text: message,
|
||||
UserName: config.UserName,
|
||||
Channel: config.Channel,
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
if value, found := (*params)["username"]; found {
|
||||
payload.UserName = value
|
||||
}
|
||||
|
||||
if value, found := (*params)["channel"]; found {
|
||||
payload.Channel = value
|
||||
}
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling Rocket.Chat payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
return payloadBytes, nil
|
||||
}
|
252
pkg/services/rocketchat/rocketchat_test.go
Normal file
252
pkg/services/rocketchat/rocketchat_test.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
package rocketchat
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
service *Service
|
||||
envRocketchatURL *url.URL
|
||||
_ = ginkgo.BeforeSuite(func() {
|
||||
service = &Service{}
|
||||
envRocketchatURL, _ = url.Parse(os.Getenv("SHOUTRRR_ROCKETCHAT_URL"))
|
||||
})
|
||||
)
|
||||
|
||||
// Constants for repeated test values.
|
||||
const (
|
||||
testTokenA = "tokenA"
|
||||
testTokenB = "tokenB"
|
||||
)
|
||||
|
||||
func TestRocketchat(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Rocketchat Suite")
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("the rocketchat service", func() {
|
||||
// Add tests for Initialize()
|
||||
ginkgo.Describe("Initialize method", func() {
|
||||
ginkgo.When("initializing with a valid URL", func() {
|
||||
ginkgo.It("should set logger and config without error", func() {
|
||||
service := &Service{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://testUser@rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB + "/#testChannel",
|
||||
)
|
||||
err := service.Initialize(testURL, testutils.TestLogger())
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config).NotTo(gomega.BeNil())
|
||||
gomega.Expect(service.Config.Host).To(gomega.Equal("rocketchat.my-domain.com"))
|
||||
gomega.Expect(service.Config.Port).To(gomega.Equal("5055"))
|
||||
gomega.Expect(service.Config.UserName).To(gomega.Equal("testUser"))
|
||||
gomega.Expect(service.Config.TokenA).To(gomega.Equal(testTokenA))
|
||||
gomega.Expect(service.Config.TokenB).To(gomega.Equal(testTokenB))
|
||||
gomega.Expect(service.Config.Channel).To(gomega.Equal("#testChannel"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("initializing with an invalid URL", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
service := &Service{}
|
||||
testURL, _ := url.Parse("rocketchat://rocketchat.my-domain.com") // Missing tokens
|
||||
err := service.Initialize(testURL, testutils.TestLogger())
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err).
|
||||
To(gomega.Equal(ErrNotEnoughArguments))
|
||||
// Updated to use the error variable
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add tests for Send()
|
||||
ginkgo.Describe("Send method", func() {
|
||||
var (
|
||||
mockServer *httptest.Server
|
||||
service *Service
|
||||
client *http.Client
|
||||
)
|
||||
|
||||
ginkgo.BeforeEach(func() {
|
||||
// Create TLS server
|
||||
mockServer = httptest.NewTLSServer(nil) // Handler set in each test
|
||||
|
||||
// Configure client to trust the mock server's certificate
|
||||
certPool := x509.NewCertPool()
|
||||
for _, cert := range mockServer.TLS.Certificates {
|
||||
certPool.AddCert(cert.Leaf)
|
||||
}
|
||||
client = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: certPool,
|
||||
MinVersion: tls.VersionTLS12, // Explicitly set minimum TLS version to 1.2
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service = &Service{
|
||||
Config: &Config{},
|
||||
Client: client, // Assign the custom client here
|
||||
}
|
||||
service.SetLogger(testutils.TestLogger())
|
||||
})
|
||||
|
||||
ginkgo.AfterEach(func() {
|
||||
if mockServer != nil {
|
||||
mockServer.Close()
|
||||
}
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to a mock server with success", func() {
|
||||
ginkgo.It("should return no error", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to a mock server with failure", func() {
|
||||
ginkgo.It("should return an error with status code and body", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("bad request"))
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).
|
||||
To(gomega.ContainSubstring("notification failed: 400 bad request"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message to an unreachable server", func() {
|
||||
ginkgo.It("should return a connection error", func() {
|
||||
service.Client = http.DefaultClient // Reset to default client for this test
|
||||
service.Config.Host = "nonexistent.domain"
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
|
||||
err := service.Send("test message", nil)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).To(gomega.ContainSubstring("posting to URL"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("sending a message with params overriding username and channel", func() {
|
||||
ginkgo.It("should use params values in the payload", func() {
|
||||
mockServer.Config.Handler = http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
)
|
||||
mockURL, _ := url.Parse(mockServer.URL)
|
||||
service.Config.Host = mockURL.Hostname()
|
||||
service.Config.Port = mockURL.Port()
|
||||
service.Config.TokenA = testTokenA
|
||||
service.Config.TokenB = testTokenB
|
||||
service.Config.UserName = "defaultUser"
|
||||
service.Config.Channel = "#defaultChannel"
|
||||
|
||||
params := types.Params{
|
||||
"username": "overrideUser",
|
||||
"channel": "#overrideChannel",
|
||||
}
|
||||
err := service.Send("test message", ¶ms)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
// Note: We can't directly inspect the payload here without mocking CreateJSONPayload,
|
||||
// but this ensures the params path is exercised.
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add tests for GetURL() and SetURL()
|
||||
ginkgo.Describe("the rocketchat config", func() {
|
||||
ginkgo.When("generating a URL from a config with all fields", func() {
|
||||
ginkgo.It("should construct a correct URL", func() {
|
||||
config := &Config{
|
||||
Host: "rocketchat.my-domain.com",
|
||||
Port: "5055",
|
||||
TokenA: testTokenA,
|
||||
TokenB: testTokenB,
|
||||
}
|
||||
url := config.GetURL()
|
||||
gomega.Expect(url.String()).
|
||||
To(gomega.Equal("rocketchat://rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("generating a URL from a config without port", func() {
|
||||
ginkgo.It("should construct a correct URL without port", func() {
|
||||
config := &Config{
|
||||
Host: "rocketchat.my-domain.com",
|
||||
TokenA: testTokenA,
|
||||
TokenB: testTokenB,
|
||||
}
|
||||
url := config.GetURL()
|
||||
gomega.Expect(url.String()).
|
||||
To(gomega.Equal("rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("setting URL with a channel starting with @", func() {
|
||||
ginkgo.It("should set channel without adding #", func() {
|
||||
config := &Config{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/@user",
|
||||
)
|
||||
err := config.SetURL(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(config.Channel).To(gomega.Equal("@user"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("setting URL with a regular channel without fragment", func() {
|
||||
ginkgo.It("should prepend # to the channel", func() {
|
||||
config := &Config{}
|
||||
testURL, _ := url.Parse(
|
||||
"rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/general",
|
||||
)
|
||||
err := config.SetURL(testURL)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(config.Channel).To(gomega.Equal("#general"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Add test for GetID()
|
||||
ginkgo.Describe("GetID method", func() {
|
||||
ginkgo.It("should return the correct scheme", func() {
|
||||
service := &Service{}
|
||||
id := service.GetID()
|
||||
gomega.Expect(id).To(gomega.Equal(Scheme))
|
||||
})
|
||||
})
|
||||
})
|
142
pkg/services/services_test.go
Normal file
142
pkg/services/services_test.go
Normal 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
142
pkg/services/slack/slack.go
Normal 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)
|
||||
}
|
||||
}
|
91
pkg/services/slack/slack_config.go
Normal file
91
pkg/services/slack/slack_config.go
Normal 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
Loading…
Add table
Add a link
Reference in a new issue