Skip to content

Commit ac1fc94

Browse files
committed
feat(containrrr#404): implement basic webex functionality to shoutrrr
1 parent 52149dc commit ac1fc94

File tree

4 files changed

+330
-0
lines changed

4 files changed

+330
-0
lines changed

pkg/router/servicemap.go

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/containrrr/shoutrrr/pkg/services/smtp"
2121
"github.com/containrrr/shoutrrr/pkg/services/teams"
2222
"github.com/containrrr/shoutrrr/pkg/services/telegram"
23+
"github.com/containrrr/shoutrrr/pkg/services/webex"
2324
"github.com/containrrr/shoutrrr/pkg/services/zulip"
2425
t "github.com/containrrr/shoutrrr/pkg/types"
2526
)
@@ -45,5 +46,6 @@ var serviceMap = map[string]func() t.Service{
4546
"smtp": func() t.Service { return &smtp.Service{} },
4647
"teams": func() t.Service { return &teams.Service{} },
4748
"telegram": func() t.Service { return &telegram.Service{} },
49+
"webex": func() t.Service { return &webex.Service{} },
4850
"zulip": func() t.Service { return &zulip.Service{} },
4951
}

pkg/services/webex/webex.go

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package webex
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
10+
"github.com/containrrr/shoutrrr/pkg/format"
11+
"github.com/containrrr/shoutrrr/pkg/services/standard"
12+
"github.com/containrrr/shoutrrr/pkg/types"
13+
)
14+
15+
// Service providing Webex as a notification service
16+
type Service struct {
17+
standard.Standard
18+
config *Config
19+
pkr format.PropKeyResolver
20+
}
21+
22+
const (
23+
MessagesEndpoint = "https://webexapis.com/v1/messages"
24+
)
25+
26+
// MessagePayload is the message endpoint payload
27+
type MessagePayload struct {
28+
RoomID string `json:"roomId"`
29+
Markdown string `json:"markdown,omitempty"`
30+
}
31+
32+
// Send a notification message to webex
33+
func (service *Service) Send(message string, params *types.Params) error {
34+
err := doSend(message, service.config)
35+
if err != nil {
36+
return fmt.Errorf("failed to send webex notification: %v", err)
37+
}
38+
39+
return nil
40+
}
41+
42+
// Initialize loads ServiceConfig from configURL and sets logger for this Service
43+
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
44+
service.Logger.SetLogger(logger)
45+
service.config = &Config{}
46+
service.pkr = format.NewPropKeyResolver(service.config)
47+
48+
if err := service.pkr.SetDefaultProps(service.config); err != nil {
49+
return err
50+
}
51+
52+
if err := service.config.SetURL(configURL); err != nil {
53+
return err
54+
}
55+
56+
return nil
57+
}
58+
59+
func doSend(message string, config *Config) error {
60+
req, err := BuildRequestFromPayloadAndConfig(message, config)
61+
if err != nil {
62+
return err
63+
}
64+
65+
res, err := http.DefaultClient.Do(req)
66+
67+
if res == nil && err == nil {
68+
err = fmt.Errorf("unknown error")
69+
}
70+
71+
if err == nil && res.StatusCode != http.StatusOK {
72+
err = fmt.Errorf("response status code %s", res.Status)
73+
}
74+
75+
return err
76+
}
77+
78+
func BuildRequestFromPayloadAndConfig(message string, config *Config) (*http.Request, error) {
79+
var err error
80+
payload := MessagePayload{
81+
RoomID: config.RoomID,
82+
Markdown: message,
83+
}
84+
85+
payloadBytes, err := json.Marshal(payload)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
req, err := http.NewRequest("POST", MessagesEndpoint, bytes.NewBuffer(payloadBytes))
91+
if err != nil {
92+
return nil, err
93+
}
94+
95+
req.Header.Add("Authorization", "Bearer "+config.BotToken)
96+
req.Header.Add("Content-Type", "application/json")
97+
98+
return req, nil
99+
}

pkg/services/webex/webex_config.go

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package webex
2+
3+
import (
4+
"errors"
5+
"net/url"
6+
7+
"github.com/containrrr/shoutrrr/pkg/format"
8+
"github.com/containrrr/shoutrrr/pkg/services/standard"
9+
"github.com/containrrr/shoutrrr/pkg/types"
10+
)
11+
12+
// Config is the configuration needed to send webex notifications
13+
type Config struct {
14+
standard.EnumlessConfig
15+
RoomID string `url:"host"`
16+
BotToken string `url:"user"`
17+
}
18+
19+
// GetURL returns a URL representation of it's current field values
20+
func (config *Config) GetURL() *url.URL {
21+
resolver := format.NewPropKeyResolver(config)
22+
return config.getURL(&resolver)
23+
}
24+
25+
// SetURL updates a ServiceConfig from a URL representation of it's field values
26+
func (config *Config) SetURL(url *url.URL) error {
27+
resolver := format.NewPropKeyResolver(config)
28+
return config.setURL(&resolver, url)
29+
}
30+
31+
func (config *Config) getURL(resolver types.ConfigQueryResolver) (u *url.URL) {
32+
u = &url.URL{
33+
User: url.User(config.BotToken),
34+
Host: config.RoomID,
35+
Scheme: Scheme,
36+
RawQuery: format.BuildQuery(resolver),
37+
ForceQuery: false,
38+
}
39+
40+
return u
41+
}
42+
43+
// SetURL updates a ServiceConfig from a URL representation of it's field values
44+
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
45+
46+
config.RoomID = url.Host
47+
config.BotToken = url.User.Username()
48+
49+
if len(url.Path) > 0 {
50+
switch url.Path {
51+
// todo: implement markdown and card functionality separately
52+
default:
53+
return errors.New("illegal argument in config URL")
54+
}
55+
}
56+
57+
if config.RoomID == "" {
58+
return errors.New("room ID missing from config URL")
59+
}
60+
61+
if len(config.BotToken) < 1 {
62+
return errors.New("bot token missing from config URL")
63+
}
64+
65+
for key, vals := range url.Query() {
66+
if err := resolver.Set(key, vals[0]); err != nil {
67+
return err
68+
}
69+
}
70+
71+
return nil
72+
}
73+
74+
// Scheme is the identifying part of this service's configuration URL
75+
const Scheme = "webex"

pkg/services/webex/webex_test.go

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package webex_test
2+
3+
import (
4+
"fmt"
5+
"log"
6+
7+
"github.com/containrrr/shoutrrr/internal/testutils"
8+
. "github.com/containrrr/shoutrrr/pkg/services/webex"
9+
"github.com/containrrr/shoutrrr/pkg/types"
10+
"github.com/jarcoal/httpmock"
11+
12+
"net/url"
13+
"os"
14+
"testing"
15+
16+
. "github.com/onsi/ginkgo/v2"
17+
. "github.com/onsi/gomega"
18+
)
19+
20+
func TestWebex(t *testing.T) {
21+
RegisterFailHandler(Fail)
22+
RunSpecs(t, "Shoutrrr Webex Suite")
23+
}
24+
25+
var (
26+
service *Service
27+
envWebexURL *url.URL
28+
logger *log.Logger
29+
_ = BeforeSuite(func() {
30+
service = &Service{}
31+
envWebexURL, _ = url.Parse(os.Getenv("SHOUTRRR_WEBEX_URL"))
32+
logger = log.New(GinkgoWriter, "Test", log.LstdFlags)
33+
})
34+
)
35+
36+
var _ = Describe("the webex service", func() {
37+
38+
When("running integration tests", func() {
39+
It("should work without errors", func() {
40+
if envWebexURL.String() == "" {
41+
return
42+
}
43+
44+
serviceURL, _ := url.Parse(envWebexURL.String())
45+
err := service.Initialize(serviceURL, testutils.TestLogger())
46+
Expect(err).NotTo(HaveOccurred())
47+
48+
err = service.Send(
49+
"this is an integration test",
50+
nil,
51+
)
52+
Expect(err).NotTo(HaveOccurred())
53+
})
54+
})
55+
Describe("the service", func() {
56+
It("should implement Service interface", func() {
57+
var impl types.Service = service
58+
Expect(impl).ToNot(BeNil())
59+
})
60+
})
61+
Describe("creating a config", func() {
62+
When("given an url and a message", func() {
63+
It("should return an error if no arguments where supplied", func() {
64+
serviceURL, _ := url.Parse("webex://")
65+
err := service.Initialize(serviceURL, nil)
66+
Expect(err).To(HaveOccurred())
67+
})
68+
It("should not return an error if exactly two arguments are given", func() {
69+
serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom")
70+
err := service.Initialize(serviceURL, nil)
71+
Expect(err).NotTo(HaveOccurred())
72+
})
73+
It("should return an error if more than two arguments are given", func() {
74+
serviceURL, _ := url.Parse("webex://dummyToken@dummyRoom/illegal-argument")
75+
err := service.Initialize(serviceURL, nil)
76+
Expect(err).To(HaveOccurred())
77+
})
78+
})
79+
When("parsing the configuration URL", func() {
80+
It("should be identical after de-/serialization", func() {
81+
testURL := "webex://token@room"
82+
83+
url, err := url.Parse(testURL)
84+
Expect(err).NotTo(HaveOccurred(), "parsing")
85+
86+
config := &Config{}
87+
err = config.SetURL(url)
88+
Expect(err).NotTo(HaveOccurred(), "verifying")
89+
90+
outputURL := config.GetURL()
91+
92+
Expect(outputURL.String()).To(Equal(testURL))
93+
94+
})
95+
})
96+
})
97+
98+
Describe("sending the payload", func() {
99+
var dummyConfig = Config{
100+
RoomID: "1",
101+
BotToken: "dummyToken",
102+
}
103+
var service Service
104+
BeforeEach(func() {
105+
httpmock.Activate()
106+
service = Service{}
107+
if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil {
108+
panic(fmt.Errorf("service initialization failed: %w", err))
109+
}
110+
})
111+
AfterEach(func() {
112+
httpmock.DeactivateAndReset()
113+
})
114+
It("should not report an error if the server accepts the payload", func() {
115+
setupResponder(&dummyConfig, 200, "")
116+
117+
Expect(service.Send("Message", nil)).To(Succeed())
118+
})
119+
It("should report an error if the server response is not OK", func() {
120+
setupResponder(&dummyConfig, 400, "")
121+
Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed())
122+
Expect(service.Send("Message", nil)).NotTo(Succeed())
123+
})
124+
It("should report an error if the message is empty", func() {
125+
setupResponder(&dummyConfig, 400, "")
126+
Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed())
127+
Expect(service.Send("", nil)).NotTo(Succeed())
128+
})
129+
})
130+
Describe("doing request", func() {
131+
dummyConfig := &Config{
132+
BotToken: "dummyToken",
133+
}
134+
135+
It("should add authorization header", func() {
136+
request, err := BuildRequestFromPayloadAndConfig("", dummyConfig)
137+
138+
Expect(err).To(BeNil())
139+
Expect(request.Header.Get("Authorization")).To(Equal("Bearer dummyToken"))
140+
})
141+
142+
// webex API rejects messages which do not define Content-Type
143+
It("should add content type header", func() {
144+
request, err := BuildRequestFromPayloadAndConfig("", dummyConfig)
145+
146+
Expect(err).To(BeNil())
147+
Expect(request.Header.Get("Content-Type")).To(Equal("application/json"))
148+
})
149+
})
150+
})
151+
152+
func setupResponder(config *Config, code int, body string) {
153+
httpmock.RegisterResponder("POST", MessagesEndpoint, httpmock.NewStringResponder(code, body))
154+
}

0 commit comments

Comments
 (0)