diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index de9f3338..1fd8c6ce 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -20,6 +20,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/services/smtp" "github.com/containrrr/shoutrrr/pkg/services/teams" "github.com/containrrr/shoutrrr/pkg/services/telegram" + "github.com/containrrr/shoutrrr/pkg/services/webex" "github.com/containrrr/shoutrrr/pkg/services/zulip" t "github.com/containrrr/shoutrrr/pkg/types" ) @@ -45,5 +46,6 @@ var serviceMap = map[string]func() t.Service{ "smtp": func() t.Service { return &smtp.Service{} }, "teams": func() t.Service { return &teams.Service{} }, "telegram": func() t.Service { return &telegram.Service{} }, + "webex": func() t.Service { return &webex.Service{} }, "zulip": func() t.Service { return &zulip.Service{} }, } diff --git a/pkg/services/webex/webex.go b/pkg/services/webex/webex.go new file mode 100644 index 00000000..3b6c0cfa --- /dev/null +++ b/pkg/services/webex/webex.go @@ -0,0 +1,99 @@ +package webex + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/standard" + "github.com/containrrr/shoutrrr/pkg/types" +) + +// Service providing Webex as a notification service +type Service struct { + standard.Standard + config *Config + pkr format.PropKeyResolver +} + +const ( + MessagesEndpoint = "https://webexapis.com/v1/messages" +) + +// MessagePayload is the message endpoint payload +type MessagePayload struct { + RoomID string `json:"roomId"` + Markdown string `json:"markdown,omitempty"` +} + +// Send a notification message to webex +func (service *Service) Send(message string, params *types.Params) error { + err := doSend(message, service.config) + if err != nil { + return fmt.Errorf("failed to send webex notification: %v", err) + } + + return nil +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.Logger.SetLogger(logger) + service.config = &Config{} + service.pkr = format.NewPropKeyResolver(service.config) + + if err := service.pkr.SetDefaultProps(service.config); err != nil { + return err + } + + if err := service.config.SetURL(configURL); err != nil { + return err + } + + return nil +} + +func doSend(message string, config *Config) error { + req, err := BuildRequestFromPayloadAndConfig(message, config) + if err != nil { + return err + } + + res, err := http.DefaultClient.Do(req) + + if res == nil && err == nil { + err = fmt.Errorf("unknown error") + } + + if err == nil && res.StatusCode != http.StatusOK { + err = fmt.Errorf("response status code %s", res.Status) + } + + return err +} + +func BuildRequestFromPayloadAndConfig(message string, config *Config) (*http.Request, error) { + var err error + payload := MessagePayload{ + RoomID: config.RoomID, + Markdown: message, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", MessagesEndpoint, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", "Bearer "+config.BotToken) + req.Header.Add("Content-Type", "application/json") + + return req, nil +} diff --git a/pkg/services/webex/webex_config.go b/pkg/services/webex/webex_config.go new file mode 100644 index 00000000..268d5664 --- /dev/null +++ b/pkg/services/webex/webex_config.go @@ -0,0 +1,75 @@ +package webex + +import ( + "errors" + "net/url" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/standard" + "github.com/containrrr/shoutrrr/pkg/types" +) + +// Config is the configuration needed to send webex notifications +type Config struct { + standard.EnumlessConfig + RoomID string `url:"host"` + BotToken string `url:"user"` +} + +// 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) (u *url.URL) { + u = &url.URL{ + User: url.User(config.BotToken), + Host: config.RoomID, + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + ForceQuery: false, + } + + return u +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + + config.RoomID = url.Host + config.BotToken = url.User.Username() + + if len(url.Path) > 0 { + switch url.Path { + // todo: implement markdown and card functionality separately + default: + return errors.New("illegal argument in config URL") + } + } + + if config.RoomID == "" { + return errors.New("room ID missing from config URL") + } + + if len(config.BotToken) < 1 { + return errors.New("bot token missing from config URL") + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return err + } + } + + return nil +} + +// Scheme is the identifying part of this service's configuration URL +const Scheme = "webex" diff --git a/pkg/services/webex/webex_test.go b/pkg/services/webex/webex_test.go new file mode 100644 index 00000000..0283b29c --- /dev/null +++ b/pkg/services/webex/webex_test.go @@ -0,0 +1,154 @@ +package webex_test + +import ( + "fmt" + "log" + + "github.com/containrrr/shoutrrr/internal/testutils" + . "github.com/containrrr/shoutrrr/pkg/services/webex" + "github.com/containrrr/shoutrrr/pkg/types" + "github.com/jarcoal/httpmock" + + "net/url" + "os" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWebex(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr Webex Suite") +} + +var ( + service *Service + envWebexURL *url.URL + logger *log.Logger + _ = BeforeSuite(func() { + service = &Service{} + envWebexURL, _ = url.Parse(os.Getenv("SHOUTRRR_WEBEX_URL")) + logger = log.New(GinkgoWriter, "Test", log.LstdFlags) + }) +) + +var _ = Describe("the webex service", func() { + + When("running integration tests", func() { + It("should work without errors", func() { + if envWebexURL.String() == "" { + return + } + + serviceURL, _ := url.Parse(envWebexURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + Expect(err).NotTo(HaveOccurred()) + + err = service.Send( + "this is an integration test", + nil, + ) + Expect(err).NotTo(HaveOccurred()) + }) + }) + Describe("the service", func() { + It("should implement Service interface", func() { + var impl types.Service = service + Expect(impl).ToNot(BeNil()) + }) + }) + Describe("creating a config", func() { + When("given an url and a message", func() { + It("should return an error if no arguments where supplied", func() { + serviceURL, _ := url.Parse("webex://") + err := service.Initialize(serviceURL, nil) + Expect(err).To(HaveOccurred()) + }) + It("should not return an error if exactly two arguments are given", func() { + serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom") + err := service.Initialize(serviceURL, nil) + Expect(err).NotTo(HaveOccurred()) + }) + It("should return an error if more than two arguments are given", func() { + serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom/illegal-argument") + err := service.Initialize(serviceURL, nil) + Expect(err).To(HaveOccurred()) + }) + }) + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + testURL := "webex://token@room" + + url, err := url.Parse(testURL) + Expect(err).NotTo(HaveOccurred(), "parsing") + + config := &Config{} + err = config.SetURL(url) + Expect(err).NotTo(HaveOccurred(), "verifying") + + outputURL := config.GetURL() + + Expect(outputURL.String()).To(Equal(testURL)) + + }) + }) + }) + + Describe("sending the payload", func() { + var dummyConfig = Config{ + RoomID: "1", + BotToken: "dummyToken", + } + var service Service + BeforeEach(func() { + httpmock.Activate() + service = Service{} + if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil { + panic(fmt.Errorf("service initialization failed: %w", err)) + } + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should not report an error if the server accepts the payload", func() { + setupResponder(&dummyConfig, 200, "") + + Expect(service.Send("Message", nil)).To(Succeed()) + }) + It("should report an error if the server response is not OK", func() { + setupResponder(&dummyConfig, 400, "") + Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed()) + Expect(service.Send("Message", nil)).NotTo(Succeed()) + }) + It("should report an error if the message is empty", func() { + setupResponder(&dummyConfig, 400, "") + Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed()) + Expect(service.Send("", nil)).NotTo(Succeed()) + }) + }) + Describe("doing request", func() { + dummyConfig := &Config{ + BotToken: "dummyToken", + } + + It("should add authorization header", func() { + request, err := BuildRequestFromPayloadAndConfig("", dummyConfig) + + Expect(err).To(BeNil()) + Expect(request.Header.Get("Authorization")).To(Equal("Bearer dummyToken")) + }) + + // webex API rejects messages which do not define Content-Type + It("should add content type header", func() { + request, err := BuildRequestFromPayloadAndConfig("", dummyConfig) + + Expect(err).To(BeNil()) + Expect(request.Header.Get("Content-Type")).To(Equal("application/json")) + }) + }) +}) + +func setupResponder(config *Config, code int, body string) { + httpmock.RegisterResponder("POST", MessagesEndpoint, httpmock.NewStringResponder(code, body)) +}