Skip to content

Commit

Permalink
fix(teams): use better URL format and nicer output (#117)
Browse files Browse the repository at this point in the history
* fix(teams): use multi-card messages, add title and color fields via pkr
* fix(teams): add tests and fix token/url validation
* fix(teams): simplify init
* feat(teams): add support for custom hosts
* fix: add comments and fix names/styling
* test(router): test for successful custom URL init
  • Loading branch information
piksel committed Jan 31, 2021
1 parent 7e4a3bf commit 61e5b37
Show file tree
Hide file tree
Showing 6 changed files with 347 additions and 96 deletions.
17 changes: 16 additions & 1 deletion pkg/router/router_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ var sr ServiceRouter

var _ = Describe("the router suite", func() {
BeforeEach(func() {
sr = ServiceRouter{}
sr = ServiceRouter{
logger: log.New(GinkgoWriter, "Test", log.LstdFlags),
}
})

When("extract service name is given a url", func() {
Expand Down Expand Up @@ -75,6 +77,19 @@ var _ = Describe("the router suite", func() {
})
})

When("initializing a service with a custom URL", func() {
It("should return an error if the service does not support it", func() {
service, err := sr.initService("log+https://hybr.is")
Expect(err).To(HaveOccurred())
Expect(service).To(BeNil())
})
It("should successfully init a service that does support it", func() {
service, err := sr.initService("teams+https://publicservice.info/webhook/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc")
Expect(err).NotTo(HaveOccurred())
Expect(service).NotTo(BeNil())
})
})

When("a message is enqueued", func() {
It("should be added to the internal queue", func() {

Expand Down
92 changes: 67 additions & 25 deletions pkg/services/teams/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/containrrr/shoutrrr/pkg/format"
"log"
"net/http"
"net/url"
"strings"

"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
Expand All @@ -16,42 +18,92 @@ import (
type Service struct {
standard.Standard
config *Config
pkr format.PropKeyResolver
}

// Send a notification message to Microsoft Teams
func (service *Service) Send(message string, params *types.Params) error {
config := service.config

postURL := buildURL(config)
return service.doSend(postURL, message)
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
service.Logf("Failed to update params: %v", err)
}

return service.doSend(config, message)
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service
func (service *Service) Initialize(configURL *url.URL, logger *log.Logger) error {
service.Logger.SetLogger(logger)
service.config = &Config{}
if err := service.config.SetURL(configURL); err != nil {
return err
service.config = &Config{
Host: DefaultHost,
}

return nil
service.pkr = format.NewPropKeyResolver(service.config)

return service.config.setURL(&service.pkr, configURL)
}

// GetConfigURLFromCustom creates a regular service URL from one with a custom host
func (*Service) GetConfigURLFromCustom(customURL *url.URL) (serviceURL *url.URL, err error) {
parts, err := parseAndVerifyWebhookURL(customURL.String())
if err != nil {
return nil, err
}

config := Config{}
resolver := format.NewPropKeyResolver(&config)
for key, vals := range customURL.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return nil, err
}
}

config.Host = customURL.Host
config.WebhookParts = parts

return config.getURL(&resolver), nil
}

func (service *Service) doSend(postURL string, message string) error {
body := JSON{
CardType: "MessageCard",
Context: "http://schema.org/extensions",
Markdown: true,
Text: message,
func (service *Service) doSend(config *Config, message string) error {
var sections []section

for _, line := range strings.Split(message, "\n") {
sections = append(sections, section{
Text: line,
})
}

// Teams need a summary for the webhook, use title or first (truncated) row
summary := config.Title
if summary == "" && len(sections) > 0 {
summary = sections[0].Text
if len(summary) > 20 {
summary = summary[:21]
}
}

jsonBody, err := json.Marshal(body)
payload, err := json.Marshal(payload{
CardType: "MessageCard",
Context: "http://schema.org/extensions",
Markdown: true,
Title: config.Title,
ThemeColor: config.Color,
Summary: summary,
Sections: sections,
})
if err != nil {
return err
}

res, err := http.Post(postURL, "application/json", bytes.NewBuffer(jsonBody))
if res.StatusCode != http.StatusOK {
host := config.Host
if host == "" {
host = DefaultHost
}
postURL := buildWebhookURL(config.Host, config.WebhookParts)

res, err := http.Post(postURL, "application/json", bytes.NewBuffer(payload))
if err == nil && res.StatusCode != http.StatusOK {
return fmt.Errorf("failed to send notification to teams, response status code %s", res.Status)
}
if err != nil {
Expand All @@ -62,13 +114,3 @@ func (service *Service) doSend(postURL string, message string) error {
}
return nil
}

func buildURL(config *Config) string {
var baseURL = "https://outlook.office.com/webhook"
return fmt.Sprintf(
"%s/%s/IncomingWebhook/%s/%s",
baseURL,
config.Token.A,
config.Token.B,
config.Token.C)
}
113 changes: 101 additions & 12 deletions pkg/services/teams/teams_config.go
Original file line number Diff line number Diff line change
@@ -1,42 +1,129 @@
package teams

import (
"fmt"
"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"
"net/url"
"regexp"
"strings"

"github.com/containrrr/shoutrrr/pkg/services/standard"
)

// Config for use within the teams plugin
type Config struct {
standard.EnumlessConfig
Token Token
WebhookParts [4]string
Title string `key:"title" optional:""`
Color string `key:"color" optional:""`
Host string `key:"host" optional:"" default:"outlook.office.com"`
}

// SetFromWebhookURL updates the config WebhookParts from a teams webhook URL
func (config *Config) SetFromWebhookURL(webhookURL string) (*Config, error) {
parts, err := parseWebhookURL(webhookURL)
if err != nil {
return nil, err
}

return &Config{WebhookParts: parts}, nil
}

// 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 {
parts := config.WebhookParts

return &url.URL{
User: url.UserPassword(config.Token.A, config.Token.B),
Host: config.Token.C,
User: url.User(parts[0]),
Host: parts[1],
Path: "/" + parts[2] + "/" + parts[3],
Scheme: Scheme,
ForceQuery: false,
RawQuery: format.BuildQuery(resolver),
}
}

// SetURL updates a ServiceConfig from a URL representation of it's field values
func (config *Config) SetURL(url *url.URL) error {
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
var webhookParts [4]string

tokenA := url.User.Username()
tokenB, _ := url.User.Password()
tokenC := url.Hostname()
if pass, legacyFormat := url.User.Password(); legacyFormat {
parts := strings.Split(url.User.Username(), "@")
if len(parts) != 2 {
return fmt.Errorf("invalid URL format")
}
webhookParts = [4]string{parts[0], parts[1], pass, url.Hostname()}
} else {
parts := strings.Split(url.Path, "/")
if parts[0] == "" {
parts = parts[1:]
}
webhookParts = [4]string{url.User.Username(), url.Hostname(), parts[0], parts[1]}
}

config.Token = Token{
A: tokenA,
B: tokenB,
C: tokenC,
if err := verifyWebhookParts(webhookParts); err != nil {
return fmt.Errorf("invalid URL format: %v", err)
}

config.WebhookParts = webhookParts

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

return nil
}

func buildWebhookURL(host string, parts [4]string) string {
return fmt.Sprintf(
"https://%s/webhook/%s@%s/IncomingWebhook/%s/%s",
host,
parts[0],
parts[1],
parts[2],
parts[3])
}

func parseWebhookURL(webhookURL string) (parts [4]string, err error) {
if len(webhookURL) < 195 {
return parts, fmt.Errorf("invalid webhook URL format")
}
return [4]string{
webhookURL[35:71],
webhookURL[72:108],
webhookURL[125:157],
webhookURL[158:194],
}, nil
}

func parseAndVerifyWebhookURL(webhookURL string) (parts [4]string, err error) {
pattern, err := regexp.Compile(`([0-9a-f-]{36})@([0-9a-f-]{36})/[^/]+/([0-9a-f]{32})/([0-9a-f-]{36})`)
if err != nil {
return parts, err
}

groups := pattern.FindStringSubmatch(webhookURL)
if len(groups) != 5 {
return parts, fmt.Errorf("invalid webhook URL format")
}

copy(parts[:], groups[1:])
return parts, nil
}

// CreateConfigFromURL for use within the teams plugin
func (service *Service) CreateConfigFromURL(url *url.URL) (*Config, error) {
config := Config{}
Expand All @@ -47,4 +134,6 @@ func (service *Service) CreateConfigFromURL(url *url.URL) (*Config, error) {
const (
// Scheme is the identifying part of this service's configuration URL
Scheme = "teams"
// DefaultHost is the default host for the webhook request
DefaultHost = "outlook.office.com"
)
26 changes: 21 additions & 5 deletions pkg/services/teams/teams_json.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
package teams

// JSON is the actual payload being sent to the teams api
type JSON struct {
CardType string `json:"@type"`
Context string `json:"@context"`
Markdown bool `json:"markdown,bool"`
Text string `json:"text,omitempty"`
type payload struct {
CardType string `json:"@type"`
Context string `json:"@context"`
Markdown bool `json:"markdown,bool"`
Text string `json:"text,omitempty"`
Title string `json:"title,omitempty"`
Summary string `json:"summary,omitempty"`
Sections []section `json:"sections,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
}

type section struct {
Text string `json:"text,omitempty"`
ActivityText string `json:"activityText,omitempty"`
StartGroup bool `json:"startGroup"`
Facts []fact `json:"facts,omitempty"`
}

type fact struct {
Key string `json:"key"`
Value string `json:"value"`
}

0 comments on commit 61e5b37

Please sign in to comment.