1
0
Fork 0

Adding upstream version 0.8.9.

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

16
internal/dedupe/dedupe.go Normal file
View file

@ -0,0 +1,16 @@
package dedupe
import "slices"
// RemoveDuplicates from a slice of strings.
func RemoveDuplicates(src []string) []string {
unique := make([]string, 0, len(src))
for _, s := range src {
found := slices.Contains(unique, s)
if !found {
unique = append(unique, s)
}
}
return unique
}

View file

@ -0,0 +1,41 @@
package dedupe_test
import (
"reflect"
"testing"
"github.com/nicholas-fedor/shoutrrr/internal/dedupe"
)
func TestRemoveDuplicates(t *testing.T) {
tests := map[string]struct {
input []string
want []string
}{
"no duplicates": {
input: []string{"a", "b", "c"},
want: []string{"a", "b", "c"},
},
"duplicate inside slice": {
input: []string{"a", "b", "a", "c"},
want: []string{"a", "b", "c"},
},
"duplicate at end of slice": {
input: []string{"a", "b", "c", "a"},
want: []string{"a", "b", "c"},
},
"duplicate next to each other inside slice": {
input: []string{"a", "b", "b", "c"},
want: []string{"a", "b", "c"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := dedupe.RemoveDuplicates(tc.input)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("expected: %#v, got: %#v", tc.want, got)
}
})
}
}

View file

@ -0,0 +1,68 @@
package failures
import "fmt"
// FailureID is a unique identifier for a specific error type.
type FailureID int
// failure is the concrete implementation of the Failure interface.
// It wraps an error with a message and an ID for categorization.
type failure struct {
message string // Descriptive message for the error
id FailureID // Unique identifier for this error type
wrapped error // Underlying error, if any, for chaining
}
// Failure extends the error interface with an ID and methods for unwrapping and comparison.
// It allows errors to be identified by a unique ID and supports Gos error wrapping conventions.
type Failure interface {
error
ID() FailureID // Returns the unique identifier for this failure
Unwrap() error // Returns the wrapped error, if any
Is(target error) bool // Checks if the target error matches this failure by ID
}
// Error returns the failures message, appending the wrapped errors message if present.
func (f *failure) Error() string {
if f.wrapped == nil {
return f.message
}
return fmt.Sprintf("%s: %v", f.message, f.wrapped)
}
// Unwrap returns the underlying error wrapped by this failure, or nil if none exists.
func (f *failure) Unwrap() error {
return f.wrapped
}
// ID returns the unique identifier assigned to this failure.
func (f *failure) ID() FailureID {
return f.id
}
// Is reports whether the target error is a failure with the same ID.
// It only returns true for failures of the same type with matching IDs.
func (f *failure) Is(target error) bool {
targetFailure, ok := target.(*failure)
return ok && targetFailure.id == f.id
}
// Wrap creates a new failure with the given message, ID, and optional wrapped error.
// If variadic arguments are provided, they are used to format the message using fmt.Sprintf.
// This supports Gos error wrapping pattern while adding a unique ID for identification.
func Wrap(message string, failureID FailureID, wrappedError error, v ...any) Failure {
if len(v) > 0 {
message = fmt.Sprintf(message, v...)
}
return &failure{
message: message,
id: failureID,
wrapped: wrappedError,
}
}
// Ensure failure implements the error interface at compile time.
var _ error = &failure{}

View file

@ -0,0 +1,174 @@
package failures_test
import (
"errors"
"fmt"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
"github.com/nicholas-fedor/shoutrrr/internal/failures"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
)
// TestFailures runs the Ginkgo test suite for the failures package.
func TestFailures(t *testing.T) {
format.CharactersAroundMismatchToInclude = 20 // Show more context in failure output
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Failure Suite")
}
var _ = ginkgo.Describe("the failure package", func() {
// Common test fixtures
var (
testID failures.FailureID = 42 // Consistent ID for testing
testMessage = "test failure occurred" // Sample error message
wrappedErr = errors.New("underlying error") // Sample wrapped error
)
ginkgo.Describe("Wrap function", func() {
ginkgo.When("creating a basic failure", func() {
ginkgo.It("returns a failure with the provided message and ID", func() {
failure := failures.Wrap(testMessage, testID, nil)
gomega.Expect(failure.Error()).To(gomega.Equal(testMessage))
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
gomega.Expect(failure.Unwrap()).To(gomega.Succeed())
})
})
ginkgo.When("wrapping an existing error", func() {
ginkgo.It("combines the message and wrapped error", func() {
failure := failures.Wrap(testMessage, testID, wrappedErr)
expectedError := fmt.Sprintf("%s: %v", testMessage, wrappedErr)
gomega.Expect(failure.Error()).To(gomega.Equal(expectedError))
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr))
})
})
ginkgo.When("using formatted message with arguments", func() {
ginkgo.It("formats the message correctly", func() {
formatMessage := "test failure %d"
failure := failures.Wrap(formatMessage, testID, nil, 123)
gomega.Expect(failure.Error()).To(gomega.Equal("test failure 123"))
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
})
})
})
ginkgo.Describe("Failure interface methods", func() {
var failure failures.Failure
// Setup a failure with a wrapped error before each test
ginkgo.BeforeEach(func() {
failure = failures.Wrap(testMessage, testID, wrappedErr)
})
ginkgo.Describe("Error method", func() {
ginkgo.It("returns only the message when no wrapped error exists", func() {
failureNoWrap := failures.Wrap(testMessage, testID, nil)
gomega.Expect(failureNoWrap.Error()).To(gomega.Equal(testMessage))
})
ginkgo.It("combines message with wrapped error", func() {
expected := fmt.Sprintf("%s: %v", testMessage, wrappedErr)
gomega.Expect(failure.Error()).To(gomega.Equal(expected))
})
})
ginkgo.Describe("ID method", func() {
ginkgo.It("returns the assigned ID", func() {
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
})
})
ginkgo.Describe("Unwrap method", func() {
ginkgo.It("returns the wrapped error", func() {
gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr))
})
ginkgo.It("returns nil when no wrapped error exists", func() {
failureNoWrap := failures.Wrap(testMessage, testID, nil)
gomega.Expect(failureNoWrap.Unwrap()).To(gomega.Succeed())
})
})
ginkgo.Describe("Is method", func() {
ginkgo.It("returns true for failures with the same ID", func() {
f1 := failures.Wrap("first", testID, nil)
f2 := failures.Wrap("second", testID, nil)
gomega.Expect(f1.Is(f2)).To(gomega.BeTrue())
gomega.Expect(f2.Is(f1)).To(gomega.BeTrue())
})
ginkgo.It("returns false for failures with different IDs", func() {
f1 := failures.Wrap("first", testID, nil)
f2 := failures.Wrap("second", testID+1, nil)
gomega.Expect(f1.Is(f2)).To(gomega.BeFalse())
gomega.Expect(f2.Is(f1)).To(gomega.BeFalse())
})
ginkgo.It("returns false when comparing with a non-failure error", func() {
f1 := failures.Wrap("first", testID, nil)
gomega.Expect(f1.Is(wrappedErr)).To(gomega.BeFalse())
})
})
})
ginkgo.Describe("edge cases", func() {
ginkgo.When("wrapping with an empty message", func() {
ginkgo.It("handles an empty message gracefully", func() {
failure := failures.Wrap("", testID, wrappedErr)
gomega.Expect(failure.Error()).To(gomega.Equal(": " + wrappedErr.Error()))
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr))
})
})
ginkgo.When("wrapping with nil error and no args", func() {
ginkgo.It("returns a valid failure with just message and ID", func() {
failure := failures.Wrap(testMessage, testID, nil)
gomega.Expect(failure.Error()).To(gomega.Equal(testMessage))
gomega.Expect(failure.ID()).To(gomega.Equal(testID))
gomega.Expect(failure.Unwrap()).To(gomega.Succeed())
})
})
ginkgo.When("using multiple wrapped failures", func() {
ginkgo.It("correctly chains and unwraps multiple errors", func() {
innerErr := errors.New("inner error")
middleErr := failures.Wrap("middle", testID+1, innerErr)
outerErr := failures.Wrap("outer", testID, middleErr)
gomega.Expect(outerErr.Error()).To(gomega.Equal("outer: middle: inner error"))
gomega.Expect(outerErr.ID()).To(gomega.Equal(testID))
gomega.Expect(outerErr.Unwrap()).To(gomega.Equal(middleErr))
gomega.Expect(middleErr.Unwrap()).To(gomega.Equal(innerErr))
})
})
})
ginkgo.Describe("integration-like scenarios", func() {
ginkgo.It("works with standard error wrapping utilities", func() {
innerErr := errors.New("inner error")
failure := failures.Wrap("wrapped failure", testID, innerErr)
gomega.Expect(errors.Is(failure, innerErr)).To(gomega.BeTrue()) // Matches wrapped error
gomega.Expect(errors.Unwrap(failure)).To(gomega.Equal(innerErr))
})
ginkgo.It("handles fmt.Errorf wrapping", func() {
failure := failures.Wrap("failure", testID, nil)
wrapped := fmt.Errorf("additional context: %w", failure)
gomega.Expect(wrapped.Error()).To(gomega.Equal("additional context: failure"))
gomega.Expect(errors.Unwrap(wrapped)).To(gomega.Equal(failure))
})
})
ginkgo.Describe("testutils integration", func() {
ginkgo.It("can use TestLogger for logging failures", func() {
// Demonstrate compatibility with testutils logger
failure := failures.Wrap("logged failure", testID, nil)
logger := testutils.TestLogger()
logger.Printf("Error occurred: %v", failure)
// No assertion needed; ensures no panic during logging
})
})
})

7
internal/meta/version.go Normal file
View file

@ -0,0 +1,7 @@
package meta
// Version of Shoutrrr.
const Version = `0.6-dev`
// DocsVersion is prepended to documentation URLs and usually equals MAJOR.MINOR of Version.
const DocsVersion = `dev`

View file

@ -0,0 +1,48 @@
package testutils
import (
"net/url"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestConfigGetInvalidQueryValue tests whether the config returns
// an error when an invalid query value is requested.
func TestConfigGetInvalidQueryValue(config types.ServiceConfig) {
value, err := format.GetConfigQueryResolver(config).Get("invalid query var")
gomega.ExpectWithOffset(1, value).To(gomega.BeEmpty())
gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred())
}
// TestConfigSetInvalidQueryValue tests whether the config returns
// an error when a URL with an invalid query value is parsed.
func TestConfigSetInvalidQueryValue(config types.ServiceConfig, rawInvalidURL string) {
invalidURL, err := url.Parse(rawInvalidURL)
gomega.ExpectWithOffset(1, err).
ToNot(gomega.HaveOccurred(), "the test URL did not parse correctly")
err = config.SetURL(invalidURL)
gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred())
}
// TestConfigSetDefaultValues tests whether setting the default values
// can be set for an empty config without any errors.
func TestConfigSetDefaultValues(config types.ServiceConfig) {
pkr := format.NewPropKeyResolver(config)
gomega.ExpectWithOffset(1, pkr.SetDefaultProps(config)).To(gomega.Succeed())
}
// TestConfigGetEnumsCount tests whether the config.Enums returns the expected amount of items.
func TestConfigGetEnumsCount(config types.ServiceConfig, expectedCount int) {
enums := config.Enums()
gomega.ExpectWithOffset(1, enums).To(gomega.HaveLen(expectedCount))
}
// TestConfigGetFieldsCount tests whether the config.QueryFields return the expected amount of fields.
func TestConfigGetFieldsCount(config types.ServiceConfig, expectedCount int) {
fields := format.GetConfigQueryResolver(config).QueryFields()
gomega.ExpectWithOffset(1, fields).To(gomega.HaveLen(expectedCount))
}

View file

@ -0,0 +1,7 @@
package testutils
// Eavesdropper is an interface that provides a way to get a summarized output of a connection RX and TX.
type Eavesdropper interface {
GetConversation(includeGreeting bool) string
GetClientSentences() []string
}

View file

@ -0,0 +1,36 @@
package testutils
import (
"errors"
"fmt"
"io"
)
var ErrWriteLimitReached = errors.New("reached write limit")
type failWriter struct {
writeLimit int
writeCount int
}
// Close is just a dummy function to implement io.Closer.
func (fw *failWriter) Close() error {
return nil
}
// Write returns an error if the write limit has been reached.
func (fw *failWriter) Write(data []byte) (int, error) {
fw.writeCount++
if fw.writeCount > fw.writeLimit {
return 0, fmt.Errorf("%w: %d", ErrWriteLimitReached, fw.writeLimit)
}
return len(data), nil
}
// CreateFailWriter returns a io.WriteCloser that returns an error after the amount of writes indicated by writeLimit.
func CreateFailWriter(writeLimit int) io.WriteCloser {
return &failWriter{
writeLimit: writeLimit,
}
}

View file

@ -0,0 +1,14 @@
package testutils
import (
"io"
)
type ioFaker struct {
io.ReadWriter
}
// Close is just a dummy function to implement the io.Closer interface.
func (iof ioFaker) Close() error {
return nil
}

View file

@ -0,0 +1,12 @@
package testutils
import (
"log"
"github.com/onsi/ginkgo/v2"
)
// TestLogger returns a log.Logger that writes to ginkgo.GinkgoWriter for use in tests.
func TestLogger() *log.Logger {
return log.New(ginkgo.GinkgoWriter, "[Test] ", 0)
}

View file

@ -0,0 +1,8 @@
package testutils
import "net/http"
// MockClientService is used to allow mocking the HTTP client when testing.
type MockClientService interface {
GetHTTPClient() *http.Client
}

View file

@ -0,0 +1,25 @@
package testutils
import (
"net/url"
"github.com/jarcoal/httpmock"
"github.com/onsi/gomega"
)
// URLMust creates a url.URL from the given rawURL and fails the test if it cannot be parsed.
func URLMust(rawURL string) *url.URL {
parsed, err := url.Parse(rawURL)
gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred())
return parsed
}
// JSONRespondMust creates a httpmock.Responder with the given response
// as the body, and fails the test if it cannot be created.
func JSONRespondMust(code int, response any) httpmock.Responder {
responder, err := httpmock.NewJsonResponder(code, response)
gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred(), "invalid test response struct")
return responder
}

View file

@ -0,0 +1,14 @@
package testutils
import (
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// TestServiceSetInvalidParamValue tests whether the service returns an error
// when an invalid param key/value is passed through Send.
func TestServiceSetInvalidParamValue(service types.Service, key string, value string) {
err := service.Send("TestMessage", &types.Params{key: value})
gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred())
}

View file

@ -0,0 +1,135 @@
package testutils_test
import (
"net/url"
"testing"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestTestUtils(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr TestUtils Suite")
}
var _ = ginkgo.Describe("the testutils package", func() {
ginkgo.When("calling function TestLogger", func() {
ginkgo.It("should not return nil", func() {
gomega.Expect(testutils.TestLogger()).NotTo(gomega.BeNil())
})
ginkgo.It(`should have the prefix "[Test] "`, func() {
gomega.Expect(testutils.TestLogger().Prefix()).To(gomega.Equal("[Test] "))
})
})
ginkgo.Describe("Must helpers", func() {
ginkgo.Describe("URLMust", func() {
ginkgo.It("should panic when an invalid URL is passed", func() {
failures := gomega.InterceptGomegaFailures(func() { testutils.URLMust(":") })
gomega.Expect(failures).To(gomega.HaveLen(1))
})
})
ginkgo.Describe("JSONRespondMust", func() {
ginkgo.It("should panic when an invalid struct is passed", func() {
notAValidJSONSource := func() {}
failures := gomega.InterceptGomegaFailures(
func() { testutils.JSONRespondMust(200, notAValidJSONSource) },
)
gomega.Expect(failures).To(gomega.HaveLen(1))
})
})
})
ginkgo.Describe("Config test helpers", func() {
var config dummyConfig
ginkgo.BeforeEach(func() {
config = dummyConfig{}
})
ginkgo.Describe("TestConfigSetInvalidQueryValue", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestConfigSetInvalidQueryValue(&config, "mock://host?invalid=value")
})
gomega.Expect(failures).To(gomega.HaveLen(1))
})
})
ginkgo.Describe("TestConfigGetInvalidQueryValue", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestConfigGetInvalidQueryValue(&config)
})
gomega.Expect(failures).To(gomega.HaveLen(1))
})
})
ginkgo.Describe("TestConfigSetDefaultValues", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestConfigSetDefaultValues(&config)
})
gomega.Expect(failures).NotTo(gomega.BeEmpty())
})
})
ginkgo.Describe("TestConfigGetEnumsCount", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestConfigGetEnumsCount(&config, 99)
})
gomega.Expect(failures).NotTo(gomega.BeEmpty())
})
})
ginkgo.Describe("TestConfigGetFieldsCount", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestConfigGetFieldsCount(&config, 99)
})
gomega.Expect(failures).NotTo(gomega.BeEmpty())
})
})
})
ginkgo.Describe("Service test helpers", func() {
var service dummyService
ginkgo.BeforeEach(func() {
service = dummyService{}
})
ginkgo.Describe("TestConfigSetInvalidQueryValue", func() {
ginkgo.It("should fail when not correctly implemented", func() {
failures := gomega.InterceptGomegaFailures(func() {
testutils.TestServiceSetInvalidParamValue(&service, "invalid", "value")
})
gomega.Expect(failures).To(gomega.HaveLen(1))
})
})
})
})
type dummyConfig struct {
standard.EnumlessConfig
Foo uint64 `default:"-1" key:"foo"`
}
func (dc *dummyConfig) GetURL() *url.URL { return &url.URL{} }
func (dc *dummyConfig) SetURL(_ *url.URL) error { return nil }
func (dc *dummyConfig) Get(string) (string, error) { return "", nil }
func (dc *dummyConfig) Set(string, string) error { return nil }
func (dc *dummyConfig) QueryFields() []string { return []string{} }
type dummyService struct {
standard.Standard
Config dummyConfig
}
func (s *dummyService) Initialize(_ *url.URL, _ types.StdLogger) error { return nil }
func (s *dummyService) Send(_ string, _ *types.Params) error { return nil }
func (s *dummyService) GetID() string { return "dummy" }

View file

@ -0,0 +1,106 @@
package testutils
import (
"bufio"
"bytes"
"fmt"
"net/textproto"
"strings"
)
type textConFaker struct {
inputBuffer *bytes.Buffer
inputWriter *bufio.Writer
outputReader *bufio.Reader
responses []string
delim string
}
func (tcf *textConFaker) GetInput() string {
_ = tcf.inputWriter.Flush()
return tcf.inputBuffer.String()
}
// GetConversation returns the input and output streams as a conversation.
func (tcf *textConFaker) GetConversation(includeGreeting bool) string {
conv := ""
inSequence := false
input := strings.Split(tcf.GetInput(), tcf.delim)
responseIndex := 0
if includeGreeting {
conv += fmt.Sprintf(" %-55s << %-50s\n", "(server greeting)", tcf.responses[0])
responseIndex = 1
}
for i, query := range input {
if query == "." {
inSequence = false
}
resp := ""
if len(tcf.responses) > responseIndex && !inSequence {
resp = tcf.responses[responseIndex]
}
if query == "" && resp == "" && i == len(input)-1 {
break
}
conv += fmt.Sprintf(" #%2d >> %50s << %-50s\n", i, query, resp)
for len(resp) > 3 && resp[3] == '-' {
responseIndex++
resp = tcf.responses[responseIndex]
conv += fmt.Sprintf(" %50s << %-50s\n", " ", resp)
}
if !inSequence {
responseIndex++
}
if len(resp) > 0 && resp[0] == '3' {
inSequence = true
}
}
return conv
}
// GetClientSentences returns all the input received from the client separated by the delimiter.
func (tcf *textConFaker) GetClientSentences() []string {
_ = tcf.inputWriter.Flush()
return strings.Split(tcf.inputBuffer.String(), tcf.delim)
}
// CreateReadWriter returns a ReadWriter from the textConFakers internal reader and writer.
func (tcf *textConFaker) CreateReadWriter() *bufio.ReadWriter {
return bufio.NewReadWriter(tcf.outputReader, tcf.inputWriter)
}
func (tcf *textConFaker) init() {
tcf.inputBuffer = &bytes.Buffer{}
stringReader := strings.NewReader(strings.Join(tcf.responses, tcf.delim))
tcf.outputReader = bufio.NewReader(stringReader)
tcf.inputWriter = bufio.NewWriter(tcf.inputBuffer)
}
// CreateTextConFaker returns a textproto.Conn to fake textproto based connections.
func CreateTextConFaker(responses []string, delim string) (*textproto.Conn, Eavesdropper) {
tcfaker := textConFaker{
responses: responses,
delim: delim,
}
tcfaker.init()
// rx := iotest.NewReadLogger("TextConRx", tcfaker.outputReader)
// tx := iotest.NewWriteLogger("TextConTx", tcfaker.inputWriter)
// faker := CreateIOFaker(rx, tx)
faker := ioFaker{
ReadWriter: tcfaker.CreateReadWriter(),
}
return textproto.NewConn(faker), &tcfaker
}

37
internal/util/cobra.go Normal file
View file

@ -0,0 +1,37 @@
package util
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// LoadFlagsFromAltSources is a WORKAROUND to make cobra count env vars and
// positional arguments when checking required flags.
func LoadFlagsFromAltSources(cmd *cobra.Command, args []string) {
flags := cmd.Flags()
if len(args) > 0 {
_ = flags.Set("url", args[0])
if len(args) > 1 {
_ = flags.Set("message", args[1])
}
return
}
if hasURLInEnvButNotFlag(cmd) {
_ = flags.Set("url", viper.GetViper().GetString("SHOUTRRR_URL"))
// If the URL has been set in ENV, default the message to read from stdin.
if msg, _ := flags.GetString("message"); msg == "" {
_ = flags.Set("message", "-")
}
}
}
func hasURLInEnvButNotFlag(cmd *cobra.Command) bool {
s, _ := cmd.Flags().GetString("url")
return s == "" && viper.GetViper().GetString("SHOUTRRR_URL") != ""
}