Skip to content

Commit

Permalink
feat(discord): format update, preparations for rich message support (#…
Browse files Browse the repository at this point in the history
…108)

* feat: add rich message API
* feat: add field base tag and default field helper
* feat(discord): update default message formatting
  also prepares for rich message sending
* fix(types): integrate rich sender into default sender interface
* format: fix naming and missing comments
* feat(config): allow numeric bases to be detected
  this includes the web color format #xxxxxx
* feat(discord): implement the rich send interface
* feat: implement simple rich send wrapper for all services
* test(discord): add black box tests
* fix: split out rich API support
  Revert "feat: implement simple rich send wrapper for all services"
  This reverts commit fe5d43f
  • Loading branch information
piksel committed Jan 31, 2021
1 parent 61e5b37 commit 8d40146
Show file tree
Hide file tree
Showing 10 changed files with 610 additions and 51 deletions.
11 changes: 8 additions & 3 deletions pkg/format/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,16 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v

} else if fieldKind >= reflect.Uint && fieldKind <= reflect.Uint64 {
var value uint64
value, err = strconv.ParseUint(inputValue, 10, field.Type.Bits())
number, base := util.StripNumberPrefix(inputValue)
value, err = strconv.ParseUint(number, base, field.Type.Bits())
if err == nil {
configField.SetUint(value)
return true, nil
}
} else if fieldKind >= reflect.Int && fieldKind <= reflect.Int64 {
var value int64
value, err = strconv.ParseInt(inputValue, 10, field.Type.Bits())
number, base := util.StripNumberPrefix(inputValue)
value, err = strconv.ParseInt(number, base, field.Type.Bits())
if err == nil {
configField.SetInt(value)
return true, nil
Expand All @@ -351,8 +353,11 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v
configField.Set(reflect.ValueOf(values))
return true, nil

} else {
err = fmt.Errorf("invalid field kind %v", fieldKind)
}
return false, nil

return false, err

}

Expand Down
69 changes: 61 additions & 8 deletions pkg/services/discord/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,96 @@ package discord

import (
"bytes"
"encoding/json"
"fmt"
"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/shoutrrr/pkg/util"
"log"
"net/http"
"net/url"

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

// Service providing Discord as a notification service
type Service struct {
standard.Standard
config *Config
pkr format.PropKeyResolver
}

var limits = types.MessageLimit{
ChunkSize: 2000,
TotalChunkSize: 6000,
ChunkCount: 10,
}

const (
hookURL = "https://discordapp.com/api/webhooks"
maxlength = 2000
hookURL = "https://discordapp.com/api/webhooks"
// Only search this many runes for a good split position
maxSearchRunes = 100
)

// Send a notification message to discord
func (service *Service) Send(message string, params *types.Params) error {

payload, err := CreateJSONToSend(message, service.config.JSON)
if service.config.JSON {
postURL := CreateAPIURLFromConfig(service.config)
return doSend([]byte(message), postURL)
}

items, omitted := CreateItemsFromPlain(message, service.config.SplitLines)
return service.sendItems(items, params, omitted)
}

// SendItems sends items with additional meta data and richer appearance
func (service *Service) SendItems(items []types.MessageItem, params *types.Params) error {
return service.sendItems(items, params, 0)
}

func (service *Service) sendItems(items []types.MessageItem, params *types.Params, omitted int) error {
var err error

config := *service.config
if err = service.pkr.UpdateConfigFromParams(&config, params); err != nil {
return err
}

var payload WebhookPayload
payload, err = CreatePayloadFromItems(items, config.Title, config.LevelColors(), omitted)
if err != nil {
return err
}

var payloadBytes []byte
payloadBytes, err = json.Marshal(payload)
if err != nil {
return err
}

postURL := CreateAPIURLFromConfig(service.config)
postURL := CreateAPIURLFromConfig(&config)
return doSend(payloadBytes, postURL)
}

// CreateItemsFromPlain creates a set of MessageItems that is compatible with Discords webhook payload
func CreateItemsFromPlain(plain string, splitLines bool) (items []types.MessageItem, omitted int) {
if splitLines {
return util.MessageItemsFromLines(plain, limits)
}

return doSend(payload, postURL)
return util.PartitionMessage(plain, limits, maxSearchRunes)
}

// 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{}
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
}
Expand Down
59 changes: 52 additions & 7 deletions pkg/services/discord/discord_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,70 @@ package discord

import (
"errors"
"net/url"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
"net/url"
)

// Config is the configuration needed to send discord notifications
type Config struct {
standard.EnumlessConfig
Channel string
Token string
JSON bool
Channel string
Token string
Title string `key:"title" default:""`
Username string `key:"username" default:"" desc:"Override the webhook default username"`
AvatarURL string `key:"avatar" default:"" desc:"Override the webhook default avatar"`
Color int `key:"color" default:"0x50D9ff" desc:"The color of the left border for plain messages" base:"16"`
ColorError int `key:"colorError" default:"0xd60510" desc:"The color of the left border for error messages" base:"16"`
ColorWarn int `key:"colorWarn" default:"0xffc441" desc:"The color of the left border for warning messages" base:"16"`
ColorInfo int `key:"colorInfo" default:"0x2488ff" desc:"The color of the left border for info messages" base:"16"`
ColorDebug int `key:"colorDebug" default:"0x7b00ab" desc:"The color of the left border for debug messages" base:"16"`
SplitLines bool `key:"splitLines" default:"yes" desc:"Whether to send each line as a separate embedded item"`
JSON bool `desc:"Whether to send the whole message as the JSON payload instead of using it as the 'content' field"`
}

// LevelColors returns an array of colors with a MessageLevel index
func (config *Config) LevelColors() (colors [types.MessageLevelCount]int) {
colors[types.Unknown] = config.Color
colors[types.Error] = config.ColorError
colors[types.Warning] = config.ColorWarn
colors[types.Info] = config.ColorInfo
colors[types.Debug] = config.ColorDebug

return colors
}

// GetURL returns a URL representation of it's current field values
func (config *Config) GetURL() *url.URL {
return &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.Token),
Host: config.Channel,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
ForceQuery: false,
}

if config.JSON {
u.Path = "/raw"
}

return u
}

// 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 {

config.Channel = url.Host
config.Token = url.User.Username()
Expand All @@ -49,6 +88,12 @@ func (config *Config) SetURL(url *url.URL) error {
return errors.New("token missing from config URL")
}

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

return nil
}

Expand Down
76 changes: 60 additions & 16 deletions pkg/services/discord/discord_json.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,71 @@
package discord

import (
"encoding/json"
"errors"
"fmt"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/shoutrrr/pkg/util"
"time"
)

// WebhookPayload is the webhook endpoint payload
type WebhookPayload struct {
Embeds []embedItem `json:"content"`
}

// JSON is the actual notification payload
type JSON struct {
Text string `json:"content"`
type embedItem struct {
Title string `json:"title,omitempty"`
Content string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Color int `json:"color,omitempty"`
Footer *embedFooter `json:"footer,omitempty"`
}

// CreateJSONToSend creates a JSON payload to be sent to the discord webhook API
func CreateJSONToSend(message string, isJSON bool) ([]byte, error) {
if message == "" {
return nil, errors.New("message was empty")
}
if len(message) > maxlength {
return nil, errors.New("the supplied message exceeds the max length for discord")
type embedFooter struct {
Text string `json:"text"`
IconURL string `json:"icon_url,omitempty"`
}

// CreatePayloadFromItems creates a JSON payload to be sent to the discord webhook API
func CreatePayloadFromItems(items []types.MessageItem, title string, colors [types.MessageLevelCount]int, omitted int) (WebhookPayload, error) {

itemCount := util.Min(9, len(items))
embeds := make([]embedItem, 1, itemCount+1)

for _, item := range items {

color := 0
if item.Level >= types.Unknown && int(item.Level) < len(colors) {
color = colors[item.Level]
}

ei := embedItem{
Content: item.Text,
Color: color,
}

if item.Level != types.Unknown {
ei.Footer = &embedFooter{
Text: item.Level.String(),
}
}

if !item.Timestamp.IsZero() {
ei.Timestamp = item.Timestamp.UTC().Format(time.RFC3339)
}

embeds = append(embeds, ei)
}
if isJSON {
return []byte(message), nil

embeds[0].Title = title
if omitted > 0 {
embeds[0].Footer = &embedFooter{
Text: fmt.Sprintf("... (%v character(s) where omitted)", omitted),
}
}
return json.Marshal(JSON{
Text: message,
})

return WebhookPayload{
Embeds: embeds,
}, nil
}

0 comments on commit 8d40146

Please sign in to comment.