Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#404): implement basic webex functionality to shoutrrr #405

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/services/webex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Webex

Adds Webex service functionality

!!! info "Your bot must be in the room"
Your bot must be invited to the room before a message can be sent by it.

## URL Format

The URL format requires the `@webex?` to be present. This is to handle the URL
being parsed properly and is ignored.

!!! info ""
```uri
webex://__`token`__@webex?rooms=__`room-1`__[,__`room-2`__,...]

--8<-- "docs/services/webex/config.md"
2 changes: 2 additions & 0 deletions pkg/router/servicemap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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{} },
}
107 changes: 107 additions & 0 deletions pkg/services/webex/webex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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 {
var firstErr error

for _, room := range config.Rooms {
req, firstErr := BuildRequestFromPayloadAndConfig(message, room, config)
if firstErr != nil {
return firstErr
}

res, firstErr := http.DefaultClient.Do(req)

if res == nil && firstErr == nil {
firstErr = fmt.Errorf("unknown error")
}

if firstErr == nil && res.StatusCode != http.StatusOK {
firstErr = fmt.Errorf("response status code %s", res.Status)
}

if firstErr != nil {
return firstErr
}
}

return firstErr
}

func BuildRequestFromPayloadAndConfig(message string, room string, config *Config) (*http.Request, error) {
var err error
payload := MessagePayload{
RoomID: room,
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
}
79 changes: 79 additions & 0 deletions pkg/services/webex/webex_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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
BotToken string `url:"user"`
Rooms []string `key:"rooms"`
}

// 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: Scheme,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
ForceQuery: true,
}

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

for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return err
}
}

if len(config.Rooms) < 1 {
return errors.New("no rooms defined in 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"
161 changes: 161 additions & 0 deletions pkg/services/webex/webex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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@webex?rooms=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{
BotToken: "dummyToken",
Rooms: []string{"1", "2"},
}

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",
Rooms: []string{"1"},
}

It("should add authorization header", func() {
request, err := BuildRequestFromPayloadAndConfig("", dummyConfig.Rooms[0], 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.Rooms[0], 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))
}