From 6cdfda1d957b54be5df2a91a3cec90b14cd6f718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arne=20J=C3=B8rgensen?= Date: Thu, 1 Jul 2021 19:21:31 +0200 Subject: [PATCH 01/22] fix(services): rename hangouts to google chat (#170) Add/keep `hangouts` as an alias scheme for `googlechat` for backward compatibility. --- docs/services/googlechat.md | 37 ++++++++++++++++++ .../{hangouts => googlechat}/hangouts-1.png | Bin .../{hangouts => googlechat}/hangouts-2.png | Bin .../{hangouts => googlechat}/hangouts-3.png | Bin .../{hangouts => googlechat}/hangouts-4.png | Bin docs/services/hangouts.md | 34 ++-------------- docs/services/overview.md | 2 +- mkdocs.yml | 2 +- pkg/router/servicemap.go | 5 ++- .../hangouts.go => googlechat/googlechat.go} | 12 +++--- .../googlechat_config.go} | 6 +-- pkg/services/googlechat/googlechat_json.go | 6 +++ pkg/services/googlechat/googlechat_test.go | 26 ++++++++++++ pkg/services/hangouts/hangouts_json.go | 6 --- pkg/services/hangouts/hangouts_test.go | 26 ------------ pkg/services/services_test.go | 1 + 16 files changed, 87 insertions(+), 76 deletions(-) create mode 100644 docs/services/googlechat.md rename docs/services/{hangouts => googlechat}/hangouts-1.png (100%) rename docs/services/{hangouts => googlechat}/hangouts-2.png (100%) rename docs/services/{hangouts => googlechat}/hangouts-3.png (100%) rename docs/services/{hangouts => googlechat}/hangouts-4.png (100%) rename pkg/services/{hangouts/hangouts.go => googlechat/googlechat.go} (79%) rename pkg/services/{hangouts/hangouts_config.go => googlechat/googlechat_config.go} (94%) create mode 100644 pkg/services/googlechat/googlechat_json.go create mode 100644 pkg/services/googlechat/googlechat_test.go delete mode 100644 pkg/services/hangouts/hangouts_json.go delete mode 100644 pkg/services/hangouts/hangouts_test.go diff --git a/docs/services/googlechat.md b/docs/services/googlechat.md new file mode 100644 index 00000000..a0ced4e6 --- /dev/null +++ b/docs/services/googlechat.md @@ -0,0 +1,37 @@ +# Google Chat + +## URL Format + +Your Google Chat Incoming Webhook URL will look like this: + +!!! info "" + https://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ + +The shoutrrr service URL should look like this: + +!!! info "" + googlechat://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ + +In other words the incoming webhook URL with `https` replaced by `googlechat`. + +Google Chat was previously known as Hangouts Chat. Using `hangouts` in +the service URL instead `googlechat` is still supported, although +deprecated. + +## Creating an incoming webhook in Google Chat + +1. Open the room you would like to add Shoutrrr to and open the chat +room menu. +![Screenshot 1](googlechat/hangouts-1.png) + +2. Then click on *Configure webhooks*. +![Screenshot 2](googlechat/hangouts-2.png) + +3. Name the webhook and save. +![Screenshot 3](googkechat/hangouts-3.png) + +4. Copy the URL. +![Screenshot 4](googlechat/hangouts-4.png) + + +5. Format the service URL by replacing `https` with `googlechat`. diff --git a/docs/services/hangouts/hangouts-1.png b/docs/services/googlechat/hangouts-1.png similarity index 100% rename from docs/services/hangouts/hangouts-1.png rename to docs/services/googlechat/hangouts-1.png diff --git a/docs/services/hangouts/hangouts-2.png b/docs/services/googlechat/hangouts-2.png similarity index 100% rename from docs/services/hangouts/hangouts-2.png rename to docs/services/googlechat/hangouts-2.png diff --git a/docs/services/hangouts/hangouts-3.png b/docs/services/googlechat/hangouts-3.png similarity index 100% rename from docs/services/hangouts/hangouts-3.png rename to docs/services/googlechat/hangouts-3.png diff --git a/docs/services/hangouts/hangouts-4.png b/docs/services/googlechat/hangouts-4.png similarity index 100% rename from docs/services/hangouts/hangouts-4.png rename to docs/services/googlechat/hangouts-4.png diff --git a/docs/services/hangouts.md b/docs/services/hangouts.md index 3d40e69c..a23f46c2 100644 --- a/docs/services/hangouts.md +++ b/docs/services/hangouts.md @@ -1,33 +1,7 @@ # Hangouts Chat -## URL Format +Google Chat was previously known as *Hangouts Chat*. See [Google +Chat](../googlechat.md). -Your Hangouts Chat Incoming Webhook URL will look like this: - -!!! info "" - https://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ - -The shoutrrr service URL should look like this: - -!!! info "" - hangouts://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ - -In other words the incoming webhook URL with `https` replaced by `hangouts`. - -## Creating an incoming webhook in Hangouts Chat - -1. Open the room you would like to add Shoutrrr to and open the chat -room menu. -![Screenshot 1](hangouts/hangouts-1.png) - -2. Then click on *Configure webhooks*. -![Screenshot 2](hangouts/hangouts-2.png) - -3. Name the webhook and save. -![Screenshot 3](hangouts/hangouts-3.png) - -4. Copy the URL. -![Screenshot 4](hangouts/hangouts-4.png) - - -5. Format the service URL by replacing `https` with `hangouts`. +Using `hangouts` in the service URL instead `googlechat` is still +supported, although deprecated. diff --git a/docs/services/overview.md b/docs/services/overview.md index 46c85ae7..d0d9287e 100644 --- a/docs/services/overview.md +++ b/docs/services/overview.md @@ -7,7 +7,7 @@ Click on the service for a more thorough explanation. | [Discord](./discord.md) | *discord://__`token`__@__`id`__* | | [Email](./email.md) | *smtp://__`username`__:__`password`__@__`host`__:__`port`__/?fromAddress=__`fromAddress`__&toAddresses=__`recipient1`__[,__`recipient2`__,...]* | | [Gotify](./gotify.md) | *gotify://__`gotify-host`__/__`token`__* | -| [Hangouts Chat](./hangouts.md) | *hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* | +| [Google Chat](./googlechat.md) | *googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* | | [IFTTT](./ifttt.md) | *ifttt://__`key`__/?events=__`event1`__[,__`event2`__,...]&value1=__`value1`__&value2=__`value2`__&value3=__`value3`__* | | [Join](./join.md) | *join://shoutrrr:__`api-key`__@join/?devices=__`device1`__[,__`device2`__, ...][&icon=__`icon`__][&title=__`title`__]* | | [Mattermost](./mattermost.md) | *mattermost://[__`username`__@]__`mattermost-host`__/__`token`__[/__`channel`__]* | diff --git a/mkdocs.yml b/mkdocs.yml index 51e2fdcb..7424afdc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,7 +30,7 @@ nav: - Discord: 'services/discord.md' - Email: 'services/email.md' - Gotify: 'services/gotify.md' - - Hangouts Chat: 'services/hangouts.md' + - Google Chat: 'services/googlechat.md' - IFTTT: 'services/ifttt.md' - Join: 'services/join.md' - Mattermost: 'services/mattermost.md' diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index 88d120e5..af74450d 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -3,8 +3,8 @@ package router import ( "github.com/containrrr/shoutrrr/pkg/services/discord" "github.com/containrrr/shoutrrr/pkg/services/generic" + "github.com/containrrr/shoutrrr/pkg/services/googlechat" "github.com/containrrr/shoutrrr/pkg/services/gotify" - "github.com/containrrr/shoutrrr/pkg/services/hangouts" "github.com/containrrr/shoutrrr/pkg/services/ifttt" "github.com/containrrr/shoutrrr/pkg/services/join" "github.com/containrrr/shoutrrr/pkg/services/logger" @@ -27,7 +27,8 @@ var serviceMap = map[string]func() t.Service{ "discord": func() t.Service { return &discord.Service{} }, "generic": func() t.Service { return &generic.Service{} }, "gotify": func() t.Service { return &gotify.Service{} }, - "hangouts": func() t.Service { return &hangouts.Service{} }, + "googlechat": func() t.Service { return &googlechat.Service{} }, + "hangouts": func() t.Service { return &googlechat.Service{} }, "ifttt": func() t.Service { return &ifttt.Service{} }, "join": func() t.Service { return &join.Service{} }, "logger": func() t.Service { return &logger.Service{} }, diff --git a/pkg/services/hangouts/hangouts.go b/pkg/services/googlechat/googlechat.go similarity index 79% rename from pkg/services/hangouts/hangouts.go rename to pkg/services/googlechat/googlechat.go index 8d5d57df..e452b707 100644 --- a/pkg/services/hangouts/hangouts.go +++ b/pkg/services/googlechat/googlechat.go @@ -1,4 +1,4 @@ -package hangouts +package googlechat import ( "bytes" @@ -11,7 +11,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/types" ) -// Service providing Hangouts Chat as a notification service. +// Service providing Google Chat as a notification service. type Service struct { standard.Standard config *Config @@ -27,14 +27,13 @@ func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) e return err } -// Send a notification message to Hangouts Chat. +// Send a notification message to Google Chat. func (service *Service) Send(message string, _ *types.Params) error { config := service.config jsonBody, err := json.Marshal(JSON{ Text: message, }) - if err != nil { return err } @@ -43,15 +42,14 @@ func (service *Service) Send(message string, _ *types.Params) error { jsonBuffer := bytes.NewBuffer(jsonBody) resp, err := http.Post(postURL.String(), "application/json", jsonBuffer) - if err != nil { - return fmt.Errorf("failed to send notification to Hangouts Chat: %s", err) + return fmt.Errorf("failed to send notification to Google Chat: %s", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("Hangouts Chat API notification returned %d HTTP status code", resp.StatusCode) + return fmt.Errorf("Google Chat API notification returned %d HTTP status code", resp.StatusCode) } return nil diff --git a/pkg/services/hangouts/hangouts_config.go b/pkg/services/googlechat/googlechat_config.go similarity index 94% rename from pkg/services/hangouts/hangouts_config.go rename to pkg/services/googlechat/googlechat_config.go index 3bd1599c..2d8dbd88 100644 --- a/pkg/services/hangouts/hangouts_config.go +++ b/pkg/services/googlechat/googlechat_config.go @@ -1,4 +1,4 @@ -package hangouts +package googlechat import ( "errors" @@ -9,7 +9,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/types" ) -// Config for use within the Hangouts Chat plugin. +// Config for use within the Google Chat plugin. type Config struct { standard.EnumlessConfig Host string `default:"chat.googleapis.com"` @@ -65,5 +65,5 @@ func (config *Config) getURL(_ types.ConfigQueryResolver) *url.URL { const ( // Scheme is the identifying part of this service's configuration URL. - Scheme = "hangouts" + Scheme = "googlechat" ) diff --git a/pkg/services/googlechat/googlechat_json.go b/pkg/services/googlechat/googlechat_json.go new file mode 100644 index 00000000..dbade820 --- /dev/null +++ b/pkg/services/googlechat/googlechat_json.go @@ -0,0 +1,6 @@ +package googlechat + +// JSON is the actual payload being sent to the Google Chat API. +type JSON struct { + Text string `json:"text"` +} diff --git a/pkg/services/googlechat/googlechat_test.go b/pkg/services/googlechat/googlechat_test.go new file mode 100644 index 00000000..2c8e64a3 --- /dev/null +++ b/pkg/services/googlechat/googlechat_test.go @@ -0,0 +1,26 @@ +package googlechat + +import ( + "net/url" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestGooglechat(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr Google Chat Suite") +} + +var _ = Describe("the Googlechat Chat plugin URL building", func() { + It("should build a valid Google Chat Incoming Webhook URL", func() { + configURL, _ := url.Parse("googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz") + + config := Config{} + config.SetURL(configURL) + + expectedURL := "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" + Expect(getAPIURL(&config).String()).To(Equal(expectedURL)) + }) +}) diff --git a/pkg/services/hangouts/hangouts_json.go b/pkg/services/hangouts/hangouts_json.go deleted file mode 100644 index 48721a03..00000000 --- a/pkg/services/hangouts/hangouts_json.go +++ /dev/null @@ -1,6 +0,0 @@ -package hangouts - -// JSON is the actual payload being sent to the Hangouts Chat API. -type JSON struct { - Text string `json:"text"` -} diff --git a/pkg/services/hangouts/hangouts_test.go b/pkg/services/hangouts/hangouts_test.go deleted file mode 100644 index 7492739f..00000000 --- a/pkg/services/hangouts/hangouts_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package hangouts - -import ( - "net/url" - "testing" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -func TestHangouts(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Shoutrrr Hangouts Chat Suite") -} - -var _ = Describe("the Hangouts Chat plugin URL building", func() { - It("should build a valid Hangouts Chat Incoming Webhook URL", func() { - configURL, _ := url.Parse("hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz") - - config := Config{} - config.SetURL(configURL) - - expectedURL := "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" - Expect(getAPIURL(&config).String()).To(Equal(expectedURL)) - }) -}) diff --git a/pkg/services/services_test.go b/pkg/services/services_test.go index d013c45d..05ad697b 100644 --- a/pkg/services/services_test.go +++ b/pkg/services/services_test.go @@ -20,6 +20,7 @@ func TestServices(t *testing.T) { var serviceURLs = map[string]string{ "discord": "discord://token@id", "gotify": "gotify://example.com/Aaa.bbb.ccc.ddd", + "googlechat": "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", "hangouts": "hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", "ifttt": "ifttt://key?events=event", "join": "join://:apikey@join/?devices=device", From 17f842b5b665f922774b867402598bfc438572ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Thu, 1 Jul 2021 19:25:34 +0200 Subject: [PATCH 02/22] fix: discord avatar override (#172) --- pkg/services/discord/discord.go | 1 + pkg/services/discord/discord_config.go | 2 +- pkg/services/discord/discord_json.go | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/services/discord/discord.go b/pkg/services/discord/discord.go index fd360c82..ae1bc6cc 100644 --- a/pkg/services/discord/discord.go +++ b/pkg/services/discord/discord.go @@ -63,6 +63,7 @@ func (service *Service) sendItems(items []types.MessageItem, params *types.Param } payload.Username = config.Username + payload.AvatarURL = config.Avatar var payloadBytes []byte payloadBytes, err = json.Marshal(payload) diff --git a/pkg/services/discord/discord_config.go b/pkg/services/discord/discord_config.go index 6d86a7ce..bcc15479 100644 --- a/pkg/services/discord/discord_config.go +++ b/pkg/services/discord/discord_config.go @@ -15,7 +15,7 @@ type Config struct { Token string `url:"user"` 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"` + Avatar string `key:"avatar,avatarurl" default:"" desc:"Override the webhook default avatar with specified URL"` Color uint `key:"color" default:"0x50D9ff" desc:"The color of the left border for plain messages" base:"16"` ColorError uint `key:"colorError" default:"0xd60510" desc:"The color of the left border for error messages" base:"16"` ColorWarn uint `key:"colorWarn" default:"0xffc441" desc:"The color of the left border for warning messages" base:"16"` diff --git a/pkg/services/discord/discord_json.go b/pkg/services/discord/discord_json.go index fa54be8f..471de794 100644 --- a/pkg/services/discord/discord_json.go +++ b/pkg/services/discord/discord_json.go @@ -11,6 +11,7 @@ import ( type WebhookPayload struct { Embeds []embedItem `json:"embeds"` Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` } // JSON is the actual notification payload From 1e34cb38495718e314612d42933e8f212c05544b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Thu, 1 Jul 2021 19:27:17 +0200 Subject: [PATCH 03/22] feat(generator): telegram generator/bot (#168) * feat(telegram): add MVP generator using bot API * simplify bot exchange, rename channels chats * add tests to generator and jsonclient * remove unused telegram client parts --- pkg/format/format.go | 4 +- pkg/format/format_colorize.go | 3 + pkg/generators/router.go | 2 + pkg/services/telegram/telegram.go | 30 +-- pkg/services/telegram/telegram_client.go | 69 +++++++ pkg/services/telegram/telegram_config.go | 10 +- pkg/services/telegram/telegram_generator.go | 149 ++++++++++++++ .../telegram/telegram_internal_test.go | 4 +- pkg/services/telegram/telegram_json.go | 161 ++++++++++++++- pkg/services/telegram/telegram_parsemode.go | 5 +- pkg/services/telegram/telegram_test.go | 24 +-- pkg/util/generator/generator_common.go | 183 ++++++++++++++++++ pkg/util/generator/generator_test.go | 169 ++++++++++++++++ pkg/util/jsonclient/error.go | 29 +++ pkg/util/jsonclient/jsonclient.go | 86 ++++++++ pkg/util/jsonclient/jsonclient_test.go | 138 +++++++++++++ 16 files changed, 1015 insertions(+), 51 deletions(-) create mode 100644 pkg/services/telegram/telegram_client.go create mode 100644 pkg/services/telegram/telegram_generator.go create mode 100644 pkg/util/generator/generator_common.go create mode 100644 pkg/util/generator/generator_test.go create mode 100644 pkg/util/jsonclient/error.go create mode 100644 pkg/util/jsonclient/jsonclient.go create mode 100644 pkg/util/jsonclient/jsonclient_test.go diff --git a/pkg/format/format.go b/pkg/format/format.go index 557495a2..e1ff0617 100644 --- a/pkg/format/format.go +++ b/pkg/format/format.go @@ -8,9 +8,9 @@ import ( // ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value func ParseBool(value string, defaultValue bool) (parsedValue bool, ok bool) { switch strings.ToLower(value) { - case "true", "1", "yes": + case "true", "1", "yes", "y": return true, true - case "false", "0", "no": + case "false", "0", "no", "n": return false, true default: return defaultValue, false diff --git a/pkg/format/format_colorize.go b/pkg/format/format_colorize.go index 601b1580..3516d9f2 100644 --- a/pkg/format/format_colorize.go +++ b/pkg/format/format_colorize.go @@ -29,6 +29,9 @@ var ColorizeError = ColorizeFalse // ColorizeContainer colorizes the input string as "Container" var ColorizeContainer = ColorizeDesc +// ColorizeLink colorizes the input string as "Link" +var ColorizeLink = color.New(color.FgHiBlue).SprintFunc() + // ColorizeValue colorizes the input string according to what type appears to be func ColorizeValue(value string, isEnum bool) string { if isEnum { diff --git a/pkg/generators/router.go b/pkg/generators/router.go index 84a68e32..092256b4 100644 --- a/pkg/generators/router.go +++ b/pkg/generators/router.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/containrrr/shoutrrr/pkg/generators/basic" "github.com/containrrr/shoutrrr/pkg/generators/xouath2" + "github.com/containrrr/shoutrrr/pkg/services/telegram" t "github.com/containrrr/shoutrrr/pkg/types" "strings" ) @@ -11,6 +12,7 @@ import ( var generatorMap = map[string]func() t.Generator{ "basic": func() t.Generator { return &basic.Generator{} }, "oauth2": func() t.Generator { return &xouath2.Generator{} }, + "telegram": func() t.Generator { return &telegram.Generator{} }, } // NewGenerator creates an instance of the generator that corresponds to the provided identifier diff --git a/pkg/services/telegram/telegram.go b/pkg/services/telegram/telegram.go index ffab9637..45f0364c 100644 --- a/pkg/services/telegram/telegram.go +++ b/pkg/services/telegram/telegram.go @@ -1,12 +1,8 @@ package telegram import ( - "bytes" - "encoding/json" "errors" - "fmt" "github.com/containrrr/shoutrrr/pkg/format" - "net/http" "net/url" "github.com/containrrr/shoutrrr/pkg/services/standard" @@ -14,7 +10,7 @@ import ( ) const ( - apiBase = "https://api.telegram.org/bot" + apiFormat = "https://api.telegram.org/bot%s/%s" maxlength = 4096 ) @@ -28,7 +24,7 @@ type Service struct { // Send notification to Telegram func (service *Service) Send(message string, params *types.Params) error { if len(message) > maxlength { - return errors.New("message exceeds the max length") + return errors.New("Message exceeds the max length") } config := *service.config @@ -55,8 +51,8 @@ func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) e } func (service *Service) sendMessageForChatIDs(message string, config *Config) error { - for _, channel := range service.config.Channels { - if err := sendMessageToAPI(message, channel, config); err != nil { + for _, chat := range service.config.Chats { + if err := sendMessageToAPI(message, chat, config); err != nil { return err } } @@ -68,19 +64,9 @@ func (service *Service) GetConfig() *Config { return service.config } -func sendMessageToAPI(message string, channel string, config *Config) error { - postURL := fmt.Sprintf("%s%s/sendMessage", apiBase, config.Token) - - payload := createSendMessagePayload(message, channel, config) - - jsonData, err := json.Marshal(payload) - if err != nil { - return err - } - - res, err := http.Post(postURL, "application/jsonData", bytes.NewBuffer(jsonData)) - if err == nil && res.StatusCode != http.StatusOK { - return fmt.Errorf("failed to send notification to \"%s\", response status code %s", channel, res.Status) - } +func sendMessageToAPI(message string, chat string, config *Config) error { + client := &Client{token: config.Token} + payload := createSendMessagePayload(message, chat, config) + _, err := client.SendMessage(&payload) return err } diff --git a/pkg/services/telegram/telegram_client.go b/pkg/services/telegram/telegram_client.go new file mode 100644 index 00000000..08624089 --- /dev/null +++ b/pkg/services/telegram/telegram_client.go @@ -0,0 +1,69 @@ +package telegram + +import ( + "encoding/json" + "fmt" + "github.com/containrrr/shoutrrr/pkg/util/jsonclient" +) + +// Client for Telegram API +type Client struct { + token string +} + +func (c *Client) apiURL(endpoint string) string { + return fmt.Sprintf(apiFormat, c.token, endpoint) +} + +// GetBotInfo returns the bot User info +func (c *Client) GetBotInfo() (*User, error) { + response := &userResponse{} + err := jsonclient.Get(c.apiURL("getMe"), response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return &response.Result, nil +} + +// GetUpdates retrieves the latest updates +func (c *Client) GetUpdates(offset int, limit int, timeout int, allowedUpdates []string) ([]Update, error) { + + request := &updatesRequest{ + Offset: offset, + Limit: limit, + Timeout: timeout, + AllowedUpdates: allowedUpdates, + } + response := &updatesResponse{} + err := jsonclient.Post(c.apiURL("getUpdates"), request, response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return response.Result, nil +} + +// SendMessage sends the specified Message +func (c *Client) SendMessage(message *SendMessagePayload) (*Message, error) { + + response := &messageResponse{} + err := jsonclient.Post(c.apiURL("sendMessage"), message, response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return response.Result, nil +} + +// GetErrorResponse retrieves the error message from a failed request +func GetErrorResponse(body string) error { + response := &errorResponse{} + if err := json.Unmarshal([]byte(body), response); err == nil { + return response + } + return nil +} diff --git a/pkg/services/telegram/telegram_config.go b/pkg/services/telegram/telegram_config.go index f249a753..002dc8a6 100644 --- a/pkg/services/telegram/telegram_config.go +++ b/pkg/services/telegram/telegram_config.go @@ -13,16 +13,16 @@ import ( type Config struct { Token string `url:"user"` Preview bool `key:"preview" default:"Yes" desc:"If disabled, no web page preview will be displayed for URLs"` - Notification bool `key:"notification" default:"Yes" desc:"If disabled, sends message silently"` - ParseMode parseMode `key:"parsemode" default:"None" desc:"How the text message should be parsed"` - Channels []string `key:"channels"` + Notification bool `key:"notification" default:"Yes" desc:"If disabled, sends Message silently"` + ParseMode parseMode `key:"parsemode" default:"None" desc:"How the text Message should be parsed"` + Chats []string `key:"chats,channels"` Title string `key:"title" default:"" desc:"Notification title, optionally set by the sender"` } // Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values func (config *Config) Enums() map[string]types.EnumFormatter { return map[string]types.EnumFormatter{ - "ParseMode": parseModes.Enum, + "ParseMode": ParseModes.Enum, } } @@ -67,7 +67,7 @@ func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) e } } - if len(config.Channels) < 1 { + if len(config.Chats) < 1 { return errors.New("no channels defined in config URL") } diff --git a/pkg/services/telegram/telegram_generator.go b/pkg/services/telegram/telegram_generator.go new file mode 100644 index 00000000..2b3e945f --- /dev/null +++ b/pkg/services/telegram/telegram_generator.go @@ -0,0 +1,149 @@ +package telegram + +import ( + f "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" + "github.com/containrrr/shoutrrr/pkg/util/generator" + "os/signal" + "syscall" + + "fmt" + "os" + "strconv" +) + +// Generator is the telegram-specific URL generator +type Generator struct { + ud *generator.UserDialog + client *Client + chats []string + chatNames []string + chatTypes []string + done bool + owner *User + statusMessage int64 + botName string +} + +// Generate a telegram Shoutrrr configuration from a user dialog +func (g *Generator) Generate(_ types.Service, props map[string]string, _ []string) (types.ServiceConfig, error) { + var config Config + + g.ud = generator.NewUserDialog(os.Stdin, os.Stdout, props) + ud := g.ud + + ud.Writeln("To start we need your bot token. If you haven't created a bot yet, you can use this link:") + ud.Writeln(" %v", f.ColorizeLink("https://t.me/botfather?start")) + ud.Writeln("") + + token := ud.QueryString("Enter your bot token:", generator.ValidateFormat(IsTokenValid), "token") + + ud.Writeln("Fetching bot info...") + // ud.Writeln("Session token: %v", g.sessionToken) + + g.client = &Client{token: token} + botInfo, err := g.client.GetBotInfo() + if err != nil { + return &Config{}, err + } + + g.botName = botInfo.Username + ud.Writeln("") + ud.Writeln("Okay! %v will listen for any messages in PMs and group chats it is invited to.", + f.ColorizeString("@", g.botName, ":")) + + g.done = false + lastUpdate := 0 + + signals := make(chan os.Signal, 1) + + // Subscribe to system signals + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + for !g.done { + + ud.Writeln("Waiting for messages to arrive...") + + updates, err := g.client.GetUpdates(lastUpdate, 10, 120, nil) + if err != nil { + panic(err) + } + + for _, update := range updates { + lastUpdate = update.UpdateID + 1 + + message := update.Message + if update.ChannelPost != nil { + message = update.ChannelPost + } + + if message != nil { + chat := message.Chat + + source := message.Chat.Username + if message.From != nil { + source = message.From.Username + } + ud.Writeln("Got Message '%v' from @%v in %v chat %v", + f.ColorizeString(message.Text), + f.ColorizeProp(source), + f.ColorizeEnum(chat.Type), + f.ColorizeNumber(chat.ID)) + ud.Writeln(g.addChat(chat)) + } else { + ud.Writeln("Got unknown Update. Ignored!") + } + } + + ud.Writeln("") + + g.done = !ud.QueryBool(fmt.Sprintf("Got %v chat ID(s) so far. Want to add some more?", + f.ColorizeNumber(len(g.chats))), "") + } + + ud.Writeln("") + ud.Writeln("Cleaning up the bot session...") + + // Notify API that we got the updates + if _, err = g.client.GetUpdates(lastUpdate, 0, 0, nil); err != nil { + g.ud.Writeln("Failed to mark last updates as received: %v", f.ColorizeError(err)) + } + + if len(g.chats) < 1 { + return nil, fmt.Errorf("no chats were selected") + } + + ud.Writeln("Selected chats:") + + for i, id := range g.chats { + name := g.chatNames[i] + chatType := g.chatTypes[i] + ud.Writeln(" %v (%v) %v", f.ColorizeNumber(id), f.ColorizeEnum(chatType), f.ColorizeString(name)) + } + + ud.Writeln("") + + config = Config{ + Notification: true, + Token: token, + Chats: g.chats, + } + + return &config, nil +} + +func (g *Generator) addChat(chat *chat) (result string) { + id := strconv.FormatInt(chat.ID, 10) + name := chat.Name() + + for _, c := range g.chats { + if c == id { + return fmt.Sprintf("chat %v is already selected!", f.ColorizeString(name)) + } + } + g.chats = append(g.chats, id) + g.chatNames = append(g.chatNames, name) + g.chatTypes = append(g.chatTypes, chat.Type) + + return fmt.Sprintf("Added new chat %v!", f.ColorizeString(name)) +} diff --git a/pkg/services/telegram/telegram_internal_test.go b/pkg/services/telegram/telegram_internal_test.go index e8864b21..bdbe652d 100644 --- a/pkg/services/telegram/telegram_internal_test.go +++ b/pkg/services/telegram/telegram_internal_test.go @@ -60,11 +60,11 @@ func getPayloadFromURL(testURL string, message string, logger *log.Logger) (Send return SendMessagePayload{}, err } - if len(telegram.config.Channels) < 1 { + if len(telegram.config.Chats) < 1 { return SendMessagePayload{}, errors.New("no channels were supplied") } - return createSendMessagePayload(message, telegram.config.Channels[0], telegram.config), nil + return createSendMessagePayload(message, telegram.config.Chats[0], telegram.config), nil } diff --git a/pkg/services/telegram/telegram_json.go b/pkg/services/telegram/telegram_json.go index c8b18139..a44930ed 100644 --- a/pkg/services/telegram/telegram_json.go +++ b/pkg/services/telegram/telegram_json.go @@ -2,11 +2,28 @@ package telegram // SendMessagePayload is the notification payload for the telegram notification service type SendMessagePayload struct { - Text string `json:"text"` - ID string `json:"chat_id"` - ParseMode string `json:"parse_mode,omitempty"` - DisablePreview bool `json:"disable_web_page_preview"` - DisableNotification bool `json:"disable_notification"` + Text string `json:"text"` + ID string `json:"chat_id"` + ParseMode string `json:"parse_mode,omitempty"` + DisablePreview bool `json:"disable_web_page_preview"` + DisableNotification bool `json:"disable_notification"` + ReplyMarkup *replyMarkup `json:"reply_markup,omitempty"` + Entities []entity `json:"entities,omitempty"` + ReplyTo int64 `json:"reply_to_message_id"` + MessageID int64 `json:"message_id,omitempty"` +} + +// Message represents one chat message +type Message struct { + MessageID int64 `json:"message_id"` + Text string `json:"text"` + From *User `json:"from"` + Chat *chat `json:"chat"` +} + +type messageResponse struct { + OK bool `json:"ok"` + Result *Message `json:"result"` } func createSendMessagePayload(message string, channel string, config *Config) SendMessagePayload { @@ -17,9 +34,141 @@ func createSendMessagePayload(message string, channel string, config *Config) Se DisablePreview: !config.Preview, } - if config.ParseMode != parseModes.None { + if config.ParseMode != ParseModes.None { payload.ParseMode = config.ParseMode.String() } return payload } + +type errorResponse struct { + OK bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +func (e *errorResponse) Error() string { + return e.Description +} + +type userResponse struct { + OK bool `json:"ok"` + Result User `json:"result"` +} + +// User contains information about a telegram user or bot +type User struct { + // Unique identifier for this User or bot + ID int64 `json:"id"` + // True, if this User is a bot + IsBot bool `json:"is_bot"` + // User's or bot's first name + FirstName string `json:"first_name"` + // Optional. User's or bot's last name + LastName string `json:"last_name"` + // Optional. User's or bot's username + Username string `json:"username"` + // Optional. IETF language tag of the User's language + LanguageCode string `json:"language_code"` + // Optional. True, if the bot can be invited to groups. Returned only in getMe. + CanJoinGroups bool `json:"can_join_groups"` + // Optional. True, if privacy mode is disabled for the bot. Returned only in getMe. + CanReadAllGroupMessages bool `json:"can_read_all_group_messages"` + // Optional. True, if the bot supports inline queries. Returned only in getMe. + SupportsInlineQueries bool `json:"supports_inline_queries"` +} + +type updatesRequest struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + Timeout int `json:"timeout"` + AllowedUpdates []string `json:"allowed_updates"` +} + +type updatesResponse struct { + OK bool `json:"ok"` + Result []Update `json:"result"` +} + +type inlineQuery struct { + // Unique identifier for this query + ID string `json:"id"` + // Sender + From User `json:"from"` + // Text of the query (up to 256 characters) + Query string `json:"query"` + // Offset of the results to be returned, can be controlled by the bot + Offset string `json:"offset"` +} + +type chosenInlineResult struct{} + +// Update contains state changes since the previous Update +type Update struct { + // The Update's unique identifier. Update identifiers start from a certain positive number and increase sequentially. This ID becomes especially handy if you're using Webhooks, since it allows you to ignore repeated updates or to restore the correct Update sequence, should they get out of order. If there are no new updates for at least a week, then identifier of the next Update will be chosen randomly instead of sequentially. + UpdateID int `json:"update_id"` + // Optional. New incoming Message of any kind — text, photo, sticker, etc. + Message *Message `json:"Message"` + // Optional. New version of a Message that is known to the bot and was edited + EditedMessage *Message `json:"edited_message"` + // Optional. New incoming channel post of any kind — text, photo, sticker, etc. + ChannelPost *Message `json:"channel_post"` + // Optional. New version of a channel post that is known to the bot and was edited + EditedChannelPost *Message `json:"edited_channel_post"` + // Optional. New incoming inline query + InlineQuery *inlineQuery `json:"inline_query"` + //// Optional. The result of an inline query that was chosen by a User and sent to their chat partner. Please see our documentation on the feedback collecting for details on how to enable these updates for your bot. + ChosenInlineResult *chosenInlineResult `json:"chosen_inline_result"` + //// Optional. New incoming callback query + CallbackQuery *callbackQuery `json:"callback_query"` + //// Optional. New incoming shipping query. Only for invoices with flexible price + //ShippingQuery ShippingQuery `json:"shipping_query"` + //// Optional. New incoming pre-checkout query. Contains full information about checkout + //PreCheckoutQuery PreCheckoutQuery `json:"pre_checkout_query"` + /* + // Optional. New poll state. Bots receive only updates about stopped polls and polls, which are sent by the bot + Poll Poll `json:"poll"` + // Optional. A User changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself. + Poll_answer PollAnswer `json:"poll_answer"` + */ +} + +type chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Username string `json:"username"` +} + +func (c *chat) Name() string { + if c.Type == "private" || c.Type == "channel" { + return "@" + c.Username + } + return c.Title +} + +type inlineKey struct { + Text string `json:"text"` + URL string `json:"url"` + LoginURL string `json:"login_url"` + CallbackData string `json:"callback_data"` + SwitchInlineQuery string `json:"switch_inline_query"` + SwitchInlineQueryCurrent string `json:"switch_inline_query_current_chat"` +} + +type replyMarkup struct { + InlineKeyboard [][]inlineKey `json:"inline_keyboard,omitempty"` +} + +type entity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +type callbackQuery struct { + ID string `json:"id"` + From *User `json:"from"` + Message *Message `json:"Message"` + Data string `json:"data"` +} diff --git a/pkg/services/telegram/telegram_parsemode.go b/pkg/services/telegram/telegram_parsemode.go index fa85dadc..eb69c717 100644 --- a/pkg/services/telegram/telegram_parsemode.go +++ b/pkg/services/telegram/telegram_parsemode.go @@ -15,7 +15,8 @@ type parseModeVals struct { Enum types.EnumFormatter } -var parseModes = &parseModeVals{ +// ParseModes is an enum helper for parseMode +var ParseModes = &parseModeVals{ None: 0, Markdown: 1, HTML: 2, @@ -30,5 +31,5 @@ var parseModes = &parseModeVals{ } func (pm parseMode) String() string { - return parseModes.Enum.Print(int(pm)) + return ParseModes.Enum.Print(int(pm)) } diff --git a/pkg/services/telegram/telegram_test.go b/pkg/services/telegram/telegram_test.go index 74eb4f99..bff1fd8e 100644 --- a/pkg/services/telegram/telegram_test.go +++ b/pkg/services/telegram/telegram_test.go @@ -43,16 +43,16 @@ var _ = Describe("the telegram service", func() { serviceURL, _ := url.Parse(envTelegramURL) err := telegram.Initialize(serviceURL, logger) Expect(err).NotTo(HaveOccurred()) - err = telegram.Send("This is an integration test message", nil) + err = telegram.Send("This is an integration test Message", nil) Expect(err).NotTo(HaveOccurred()) }) - When("given a message that exceeds the max length", func() { + When("given a Message that exceeds the max length", func() { It("should generate an error", func() { if envTelegramURL == "" { return } hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error" - serviceURL, _ := url.Parse("telegram://12345:mock-token/channel-1") + serviceURL, _ := url.Parse("telegram://12345:mock-token/?chats=channel-1") builder := strings.Builder{} for i := 0; i < 42; i++ { builder.WriteString(hundredChars) @@ -69,8 +69,8 @@ var _ = Describe("the telegram service", func() { return } It("should generate a 401", func() { - serviceURL, _ := url.Parse("telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram/?channels=channel-id") - message := "this is a perfectly valid message" + serviceURL, _ := url.Parse("telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram/?chats=channel-id") + message := "this is a perfectly valid Message" err := telegram.Initialize(serviceURL, logger) Expect(err).NotTo(HaveOccurred()) @@ -99,7 +99,7 @@ var _ = Describe("the telegram service", func() { var err error BeforeEach(func() { - serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?channels=channel-1,channel-2,channel-3") + serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3") err = telegram.Initialize(serviceURL, logger) config = telegram.GetConfig() }) @@ -113,9 +113,9 @@ var _ = Describe("the telegram service", func() { Expect(err).NotTo(HaveOccurred()) Expect(config.Token).To(Equal("12345:mock-token")) }) - It("should add every subsequent argument as a channel id", func() { + It("should add every chats query field as a chat ID", func() { Expect(err).NotTo(HaveOccurred()) - Expect(config.Channels).To(Equal([]string{ + Expect(config.Chats).To(Equal([]string{ "channel-1", "channel-2", "channel-3", @@ -134,7 +134,7 @@ var _ = Describe("the telegram service", func() { httpmock.DeactivateAndReset() }) It("should not report an error if the server accepts the payload", func() { - serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?channels=channel-1,channel-2,channel-3") + serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3") err = telegram.Initialize(serviceURL, logger) Expect(err).NotTo(HaveOccurred()) @@ -148,10 +148,10 @@ var _ = Describe("the telegram service", func() { It("should implement basic service API methods correctly", func() { testutils.TestConfigGetInvalidQueryValue(&Config{}) - testutils.TestConfigSetInvalidQueryValue(&Config{}, "telegram://12345:mock-token@telegram/?channels=channel-1&foo=bar") + testutils.TestConfigSetInvalidQueryValue(&Config{}, "telegram://12345:mock-token@telegram/?chats=channel-1&foo=bar") testutils.TestConfigGetEnumsCount(&Config{}, 1) - testutils.TestConfigGetFieldsCount(&Config{}, 5) + testutils.TestConfigGetFieldsCount(&Config{}, 6) }) }) @@ -162,7 +162,7 @@ func expectErrorAndEmptyObject(telegram *Service, rawURL string, logger *log.Log config := telegram.GetConfig() fmt.Printf("Token: \"%+v\" \"%s\" \n", config.Token, config.Token) Expect(config.Token).To(BeEmpty()) - Expect(len(config.Channels)).To(BeZero()) + Expect(len(config.Chats)).To(BeZero()) } func setupResponder(endpoint string, token string, code int, body string) { diff --git a/pkg/util/generator/generator_common.go b/pkg/util/generator/generator_common.go new file mode 100644 index 00000000..9bc84434 --- /dev/null +++ b/pkg/util/generator/generator_common.go @@ -0,0 +1,183 @@ +package generator + +import ( + "bufio" + "errors" + "fmt" + f "github.com/containrrr/shoutrrr/pkg/format" + "github.com/fatih/color" + "io" + re "regexp" + "strconv" +) + +var errInvalidFormat = errors.New("invalid format") + +// ValidateFormat is a validation wrapper turning false bool results into errors +func ValidateFormat(validator func(string) bool) func(string) error { + return func(answer string) error { + if validator(answer) { + return nil + } + return errInvalidFormat + } +} + +var errRequired = errors.New("field is required") + +// Required is a validator that checks whether the input contains any characters +func Required(answer string) error { + if answer == "" { + return errRequired + } + return nil +} + +// UserDialog is an abstraction for question/answer based user interaction +type UserDialog struct { + reader io.Reader + writer io.Writer + scanner *bufio.Scanner + props map[string]string +} + +// NewUserDialog initializes a UserDialog with safe defaults +func NewUserDialog(reader io.Reader, writer io.Writer, props map[string]string) *UserDialog { + if props == nil { + props = map[string]string{} + } + return &UserDialog{ + reader: reader, + writer: writer, + scanner: bufio.NewScanner(reader), + props: props, + } +} + +// Write message to user +func (ud *UserDialog) Write(message string, v ...interface{}) { + if _, err := fmt.Fprintf(ud.writer, message, v...); err != nil { + fmt.Printf("failed to write to output: %v", err) + } +} + +// Writeln writes a message to the user that completes a line +func (ud *UserDialog) Writeln(format string, v ...interface{}) { + ud.Write(format+"\n", v...) +} + +// Query writes the prompt to the user and returns the regex groups if it matches the validator pattern +func (ud *UserDialog) Query(prompt string, validator *re.Regexp, key string) (groups []string) { + ud.QueryString(prompt, ValidateFormat(func(answer string) bool { + groups = validator.FindStringSubmatch(answer) + return groups != nil + }), key) + + return groups +} + +// QueryAll is a version of Query that can return multiple matches +func (ud *UserDialog) QueryAll(prompt string, validator *re.Regexp, key string, maxMatches int) (matches [][]string) { + ud.QueryString(prompt, ValidateFormat(func(answer string) bool { + matches = validator.FindAllStringSubmatch(answer, maxMatches) + return matches != nil + }), key) + + return matches +} + +// QueryString writes the prompt to the user and returns the answer if it passes the validator function +func (ud *UserDialog) QueryString(prompt string, validator func(string) error, key string) string { + + if validator == nil { + validator = func(string) error { + return nil + } + } + + answer, foundProp := ud.props[key] + if foundProp { + err := validator(answer) + colAnswer := f.ColorizeValue(answer, false) + colKey := f.ColorizeProp(key) + if err == nil { + ud.Writeln("Using prop value %v for %v", colAnswer, colKey) + return answer + } + ud.Writeln("Supplied prop value %v is not valid for %v: %v", colAnswer, colKey, err) + } + + for { + ud.Write("%v ", prompt) + color.Set(color.FgHiWhite) + if !ud.scanner.Scan() { + if err := ud.scanner.Err(); err != nil { + ud.Writeln(err.Error()) + continue + } + + // Input closed, so let's just return an empty string + return "" + } + answer = ud.scanner.Text() + color.Unset() + + if err := validator(answer); err != nil { + ud.Writeln("%v", err) + continue + } + return answer + } +} + +// QueryStringPattern is a version of QueryString taking a regular expression pattern as the validator +func (ud *UserDialog) QueryStringPattern(prompt string, validator *re.Regexp, key string) (answer string) { + + if validator == nil { + panic("validator cannot be nil") + } + + return ud.QueryString(prompt, func(s string) error { + if validator.MatchString(s) { + return nil + } + return errInvalidFormat + }, key) +} + +// QueryInt writes the prompt to the user and returns the answer if it can be parsed as an integer +func (ud *UserDialog) QueryInt(prompt string, key string, bitSize int) (value int64) { + validator := re.MustCompile(`^((0x|#)([0-9a-fA-F]+))|(-?[0-9]+)$`) + ud.QueryString(prompt, func(answer string) error { + groups := validator.FindStringSubmatch(answer) + if len(groups) < 1 { + return errors.New("not a number") + } + number := groups[0] + base := 0 + if groups[2] == "#" { + // Explicitly treat #ffa080 as hexadecimal + base = 16 + number = groups[3] + } + + var err error + value, err = strconv.ParseInt(number, base, bitSize) + + return err + }, key) + return value +} + +// QueryBool writes the prompt to the user and returns the answer if it can be parsed as a boolean +func (ud *UserDialog) QueryBool(prompt string, key string) (value bool) { + ud.QueryString(prompt, func(answer string) error { + parsed, ok := f.ParseBool(answer, false) + if ok { + value = parsed + return nil + } + return fmt.Errorf("answer using %v or %v", f.ColorizeTrue("yes"), f.ColorizeFalse("no")) + }, key) + return value +} diff --git a/pkg/util/generator/generator_test.go b/pkg/util/generator/generator_test.go new file mode 100644 index 00000000..645fb665 --- /dev/null +++ b/pkg/util/generator/generator_test.go @@ -0,0 +1,169 @@ +package generator_test + +import ( + "fmt" + "github.com/containrrr/shoutrrr/pkg/util/generator" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + re "regexp" + "strings" + "testing" +) + +func TestGenerator(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Generator Suite") +} + +var ( + client *generator.UserDialog + userOut *gbytes.Buffer + userIn *gbytes.Buffer +) + +func mockTyped(a ...interface{}) { + _, _ = fmt.Fprint(userOut, a...) + _, _ = fmt.Fprint(userOut, "\n") +} + +func dumpBuffers() { + for _, line := range strings.Split(string(userIn.Contents()), "\n") { + println(">", line) + } + for _, line := range strings.Split(string(userOut.Contents()), "\n") { + println("<", line) + } +} + +var _ = Describe("GeneratorCommon", func() { + Describe("attach to the data stream", func() { + + BeforeEach(func() { + userOut = gbytes.NewBuffer() + userIn = gbytes.NewBuffer() + client = generator.NewUserDialog(userOut, userIn, map[string]string{"propKey": "propVal"}) + }) + + It("reprompt upon invalid answers", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "") + }() + + mockTyped("") + mockTyped("Normal Human Name") + + Eventually(userIn).Should(gbytes.Say(`name: `)) + + Eventually(userIn).Should(gbytes.Say(`field is required`)) + Eventually(userIn).Should(gbytes.Say(`name: `)) + Eventually(answer).Should(Receive(Equal("Normal Human Name"))) + }) + + It("should accept any input when validator is nil", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", nil, "") + }() + mockTyped("") + Eventually(answer).Should(Receive(BeEmpty())) + }) + + It("should use predefined prop value if key is present", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "propKey") + }() + Eventually(answer).Should(Receive(Equal("propVal"))) + }) + + It("Query", func() { + defer dumpBuffers() + answer := make(chan []string) + query := "pick foo or bar:" + go func() { + answer <- client.Query(query, re.MustCompile("(foo|bar)"), "") + }() + + mockTyped("") + mockTyped("foo") + + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(userIn).Should(gbytes.Say(`invalid format`)) + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(answer).Should(Receive(ContainElement("foo"))) + }) + + It("QueryAll", func() { + defer dumpBuffers() + answer := make(chan [][]string) + query := "pick foo or bar:" + go func() { + answer <- client.QueryAll(query, re.MustCompile(`foo(ba[rz])`), "", -1) + }() + + mockTyped("foobar foobaz") + + Eventually(userIn).Should(gbytes.Say(query)) + var matches [][]string + Eventually(answer).Should(Receive(&matches)) + Expect(matches).To(ContainElement([]string{"foobar", "bar"})) + Expect(matches).To(ContainElement([]string{"foobaz", "baz"})) + }) + + It("QueryStringPattern", func() { + defer dumpBuffers() + answer := make(chan string) + query := "type of bar:" + go func() { + answer <- client.QueryStringPattern(query, re.MustCompile(".*bar"), "") + }() + + mockTyped("foo") + mockTyped("foobar") + + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(userIn).Should(gbytes.Say(`invalid format`)) + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(answer).Should(Receive(Equal("foobar"))) + }) + + It("QueryInt", func() { + defer dumpBuffers() + answer := make(chan int64) + query := "number:" + go func() { + answer <- client.QueryInt(query, "", 64) + }() + + mockTyped("x") + mockTyped("0x20") + + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(userIn).Should(gbytes.Say(`not a number`)) + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(answer).Should(Receive(Equal(int64(32)))) + }) + + It("QueryBool", func() { + defer dumpBuffers() + answer := make(chan bool) + query := "cool?" + go func() { + answer <- client.QueryBool(query, "") + }() + + mockTyped("maybe") + mockTyped("y") + + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(userIn).Should(gbytes.Say(`answer using yes or no`)) + Eventually(userIn).Should(gbytes.Say(query)) + Eventually(answer).Should(Receive(BeTrue())) + }) + }) +}) diff --git a/pkg/util/jsonclient/error.go b/pkg/util/jsonclient/error.go new file mode 100644 index 00000000..7317f8ae --- /dev/null +++ b/pkg/util/jsonclient/error.go @@ -0,0 +1,29 @@ +package jsonclient + +import "fmt" + +// Error contains additional http/JSON details +type Error struct { + StatusCode int + Body string + err error +} + +func (je Error) Error() string { + return je.String() +} + +func (je Error) String() string { + if je.err == nil { + return fmt.Sprintf("unknown error (HTTP %v)", je.StatusCode) + } + return je.err.Error() +} + +// ErrorBody returns the request body from an Error +func ErrorBody(e error) string { + if jsonError, ok := e.(Error); ok { + return jsonError.Body + } + return "" +} diff --git a/pkg/util/jsonclient/jsonclient.go b/pkg/util/jsonclient/jsonclient.go new file mode 100644 index 00000000..a6396d0f --- /dev/null +++ b/pkg/util/jsonclient/jsonclient.go @@ -0,0 +1,86 @@ +package jsonclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// ContentType is the default mime type for JSON +const ContentType = "application/json" + +// DefaultClient is the singleton instance of jsonclient using http.DefaultClient +var DefaultClient = &Client{HTTPClient: http.DefaultClient} + +// Get fetches url using GET and unmarshals into the passed response using DefaultClient +func Get(url string, response interface{}) error { + return DefaultClient.Get(url, response) +} + +// Post sends request as JSON and unmarshals the response JSON into the supplied struct using DefaultClient +func Post(url string, request interface{}, response interface{}) error { + return DefaultClient.Post(url, request, response) +} + +// Client is a JSON wrapper around http.Client +type Client struct { + HTTPClient *http.Client +} + +// Get fetches url using GET and unmarshals into the passed response +func (c *Client) Get(url string, response interface{}) error { + res, err := c.HTTPClient.Get(url) + if err != nil { + return err + } + + return parseResponse(res, response) +} + +// Post sends request as JSON and unmarshals the response JSON into the supplied struct +func (c *Client) Post(url string, request interface{}, response interface{}) error { + + var err error + var body []byte + + body, err = json.Marshal(request) + if err != nil { + return fmt.Errorf("error creating payload: %v", err) + } + + var res *http.Response + res, err = c.HTTPClient.Post(url, ContentType, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("error sending payload: %v", err) + } + + return parseResponse(res, response) +} + +func parseResponse(res *http.Response, response interface{}) error { + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + + if res.StatusCode >= 400 { + err = fmt.Errorf("got HTTP %v", res.Status) + } + + if err == nil { + err = json.Unmarshal(body, response) + } + + if err != nil { + if body == nil { + body = []byte{} + } + return Error{ + StatusCode: res.StatusCode, + Body: string(body), + err: err, + } + } + + return nil +} diff --git a/pkg/util/jsonclient/jsonclient_test.go b/pkg/util/jsonclient/jsonclient_test.go new file mode 100644 index 00000000..23dbf8d9 --- /dev/null +++ b/pkg/util/jsonclient/jsonclient_test.go @@ -0,0 +1,138 @@ +package jsonclient_test + +import ( + "errors" + "github.com/containrrr/shoutrrr/pkg/util/jsonclient" + "github.com/onsi/gomega/ghttp" + "net/http" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestJSONClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "JSONClient Suite") +} + +var _ = Describe("JSONClient", func() { + var server *ghttp.Server + + BeforeEach(func() { + server = ghttp.NewServer() + }) + + When("the server returns an invalid JSON response", func() { + It("should return an error", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid json")) + res := &mockResponse{} + err := jsonclient.Get(server.URL(), &res) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).To(MatchError("invalid character 'i' looking for beginning of value")) + Expect(res.Status).To(BeEmpty()) + }) + }) + + When("the server returns an empty response", func() { + It("should return an error", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, nil)) + res := &mockResponse{} + err := jsonclient.Get(server.URL(), &res) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).To(MatchError("unexpected end of JSON input")) + Expect(res.Status).To(BeEmpty()) + }) + }) + + It("should deserialize GET response", func() { + server.AppendHandlers(ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"})) + res := &mockResponse{} + err := jsonclient.Get(server.URL(), &res) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Status).To(Equal("OK")) + }) + + Describe("POST", func() { + It("should de-/serialize request and response", func() { + + req := &mockRequest{Number: 5} + res := &mockResponse{} + + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.VerifyJSONRepresenting(&req), + ghttp.RespondWithJSONEncoded(http.StatusOK, &mockResponse{Status: "That's Numberwang!"})), + ) + + err := jsonclient.Post(server.URL(), &req, &res) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).ToNot(HaveOccurred()) + Expect(res.Status).To(Equal("That's Numberwang!")) + }) + + It("should return error on error status responses", func() { + server.AppendHandlers(ghttp.RespondWith(404, "Not found!")) + err := jsonclient.Post(server.URL(), &mockRequest{}, &mockResponse{}) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).To(MatchError("got HTTP 404 Not Found")) + }) + + It("should return error on invalid request", func() { + server.AppendHandlers(ghttp.VerifyRequest("POST", "/")) + err := jsonclient.Post(server.URL(), func() {}, &mockResponse{}) + Expect(server.ReceivedRequests()).Should(HaveLen(0)) + Expect(err).To(MatchError("error creating payload: json: unsupported type: func()")) + }) + + It("should return error on invalid response type", func() { + res := &mockResponse{Status: "cool skirt"} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.RespondWithJSONEncoded(http.StatusOK, res)), + ) + + err := jsonclient.Post(server.URL(), nil, &[]bool{}) + Expect(server.ReceivedRequests()).Should(HaveLen(1)) + Expect(err).To(MatchError("json: cannot unmarshal object into Go value of type []bool")) + Expect(jsonclient.ErrorBody(err)).To(MatchJSON(`{"Status":"cool skirt"}`)) + }) + }) + + AfterEach(func() { + //shut down the server between tests + server.Close() + }) +}) + +var _ = Describe("Error", func() { + When("no internal error has been set", func() { + It("should return a generic message with status code", func() { + errorWithNoError := jsonclient.Error{StatusCode: http.StatusEarlyHints} + Expect(errorWithNoError.String()).To(Equal("unknown error (HTTP 103)")) + }) + }) + Describe("ErrorBody", func() { + When("passed a non-json error", func() { + It("should return an empty string", func() { + Expect(jsonclient.ErrorBody(errors.New("unrelated error"))).To(BeEmpty()) + }) + }) + When("passed a jsonclient.Error", func() { + It("should return the request body from that error", func() { + errorBody := `{"error": "bad user"}` + jsonError := jsonclient.Error{Body: errorBody} + Expect(jsonclient.ErrorBody(jsonError)).To(MatchJSON(errorBody)) + }) + }) + }) +}) + +type mockResponse struct { + Status string +} + +type mockRequest struct { + Number int +} From a86ddc7dedc6d52c0394f6d4486e1fa757562039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sat, 3 Jul 2021 10:37:46 +0200 Subject: [PATCH 04/22] docs: add multi-version support (#178) * docs: add mike support * docs: update dev docs * ci: build and deploy mike docs * fix secret variable --- .github/workflows/docs.yml | 35 ++++++++++++++++++++++++----------- docs/overrides/main.html | 9 +++++++++ docs/services/generic.md | 5 +++++ docs/services/googlechat.md | 2 +- docs/services/hangouts.md | 2 +- docs/services/logger.md | 6 ++++++ docs/services/overview.md | 12 +++--------- mkdocs.yml | 6 ++++++ 8 files changed, 55 insertions(+), 22 deletions(-) create mode 100644 docs/overrides/main.html create mode 100644 docs/services/generic.md create mode 100644 docs/services/logger.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a30e245a..be9bf534 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,8 +3,12 @@ name: Deploy Docs on: workflow_dispatch: workflow_run: - workflows: ["Release Workflow"] - types: [completed] + workflows: [ "Release Workflow" ] + types: [ completed ] + push: + branches: + - main + - latest jobs: build: @@ -20,20 +24,29 @@ jobs: python-version: '3.x' - name: Install mkdocs + env: + MKDOCSMAT_TOKEN: ${{ secrets.MKDOCSMAT_TOKEN }} run: | pip install \ mkdocs \ - mkdocs-material \ - md-toc + mike \ + md-toc \ + git+https://$MKDOCSMAT_TOKEN@github.com/squidfunk/mkdocs-material-insiders.git - name: Generate service config docs run: bash generate-service-config-docs.sh - - name: Generate docs - run: mkdocs build + - name: Update env for docs (dev/main) + if: ${{ github.ref == 'refs/heads/main' }} + run: | + echo "DOCS_EDIT_URI=/edit/main/docs/" >> $GITHUB_ENV + echo "DOCS_VERSION=dev" >> $GITHUB_ENV - - name: Publish docs - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site \ No newline at end of file + - name: Update env (latest) + if: ${{ github.ref == 'refs/heads/latest' }} + run: | + echo "DOCS_EDIT_URI=/edit/latest/docs/" >> $GITHUB_ENV + echo "DOCS_VERSION=latest" >> $GITHUB_ENV + + - name: Deploy docs + run: mike deploy --push $DOCS_VERSION diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 00000000..67585023 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block outdated %} +You're not viewing the latest version. + + + Click here to go to latest. + +{% endblock %} \ No newline at end of file diff --git a/docs/services/generic.md b/docs/services/generic.md new file mode 100644 index 00000000..eadff055 --- /dev/null +++ b/docs/services/generic.md @@ -0,0 +1,5 @@ +# Generic + +## URL Format + +--8<-- "docs/services/generic/config.md" \ No newline at end of file diff --git a/docs/services/googlechat.md b/docs/services/googlechat.md index a0ced4e6..21c85d32 100644 --- a/docs/services/googlechat.md +++ b/docs/services/googlechat.md @@ -28,7 +28,7 @@ room menu. ![Screenshot 2](googlechat/hangouts-2.png) 3. Name the webhook and save. -![Screenshot 3](googkechat/hangouts-3.png) +![Screenshot 3](googlechat/hangouts-3.png) 4. Copy the URL. ![Screenshot 4](googlechat/hangouts-4.png) diff --git a/docs/services/hangouts.md b/docs/services/hangouts.md index a23f46c2..2421a4c5 100644 --- a/docs/services/hangouts.md +++ b/docs/services/hangouts.md @@ -1,7 +1,7 @@ # Hangouts Chat Google Chat was previously known as *Hangouts Chat*. See [Google -Chat](../googlechat.md). +Chat](googlechat.md). Using `hangouts` in the service URL instead `googlechat` is still supported, although deprecated. diff --git a/docs/services/logger.md b/docs/services/logger.md new file mode 100644 index 00000000..9db6e86f --- /dev/null +++ b/docs/services/logger.md @@ -0,0 +1,6 @@ +# Logger + +No configuration options are available for this service. + +It simply emits notifications to the Shoutrrr log which is +configured by the consumer. \ No newline at end of file diff --git a/docs/services/overview.md b/docs/services/overview.md index d0d9287e..0b27992a 100644 --- a/docs/services/overview.md +++ b/docs/services/overview.md @@ -7,10 +7,11 @@ Click on the service for a more thorough explanation. | [Discord](./discord.md) | *discord://__`token`__@__`id`__* | | [Email](./email.md) | *smtp://__`username`__:__`password`__@__`host`__:__`port`__/?fromAddress=__`fromAddress`__&toAddresses=__`recipient1`__[,__`recipient2`__,...]* | | [Gotify](./gotify.md) | *gotify://__`gotify-host`__/__`token`__* | -| [Google Chat](./googlechat.md) | *googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* | +| [Google Chat](./googlechat.md) | *googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* | | [IFTTT](./ifttt.md) | *ifttt://__`key`__/?events=__`event1`__[,__`event2`__,...]&value1=__`value1`__&value2=__`value2`__&value3=__`value3`__* | | [Join](./join.md) | *join://shoutrrr:__`api-key`__@join/?devices=__`device1`__[,__`device2`__, ...][&icon=__`icon`__][&title=__`title`__]* | | [Mattermost](./mattermost.md) | *mattermost://[__`username`__@]__`mattermost-host`__/__`token`__[/__`channel`__]* | +| [Matrix](./matrix.md) | *matrix://__`username`__:__`password`__@__`host`__:__`port`__/[?rooms=__`!roomID1`__[,__`roomAlias2`__]]* | | [OpsGenie](./opsgenie.md) | *opsgenie://__`host`__/token?responders=__`responder1`__[,__`responder2`__]* | | [Pushbullet](./pushbullet.md) | *pushbullet://__`api-token`__[/__`device`__/#__`channel`__/__`email`__]* | | [Pushover](./pushover.md) | *pushover://shoutrrr:__`apiToken`__@__`userKey`__/?devices=__`device1`__[,__`device2`__, ...]* | @@ -25,12 +26,5 @@ Click on the service for a more thorough explanation. | Service | Description | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Logger](./logger.md) | Writes notification to a configured go `log.Logger` | - -## Upcoming services - -*Note that these are not available in the current release* - -| Service | Description | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Generic Webhook](./generic.md) | Sends notifications directly to a webhook | -| [Matrix](./matrix.md) | *matrix://__`username`__:__`password`__@__`host`__:__`port`__/[?rooms=__`!roomID1`__[,__`roomAlias2`__]]* | + diff --git a/mkdocs.yml b/mkdocs.yml index 7424afdc..78e6021b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,11 @@ theme: scheme: shoutrrr logo: shoutrrr-180px.png favicon: favicon.ico + custom_dir: docs/overrides +extra: + version: + provider: mike + generator: false extra_css: - stylesheets/theme.css markdown_extensions: @@ -34,6 +39,7 @@ nav: - IFTTT: 'services/ifttt.md' - Join: 'services/join.md' - Mattermost: 'services/mattermost.md' + - Matrix: 'services/matrix.md' - OpsGenie: 'services/opsgenie.md' - Pushbullet: 'services/pushbullet.md' - Pushover: 'services/pushover.md' From dbe0e3b4980ce16c335d50ee8708971a663409fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 4 Jul 2021 02:40:06 +0200 Subject: [PATCH 05/22] cleanup(pushbullet): TLC (#180) - increase test coverage - implement missing parts of the Push API - actually handle errors (and return API errors to consumer) - use common service helpers / JSON Client - title is now actually settable the same way as all other service props --- pkg/services/pushbullet/pushbullet.go | 98 +++++++------------- pkg/services/pushbullet/pushbullet_config.go | 63 ++++++++----- pkg/services/pushbullet/pushbullet_json.go | 81 +++++++++------- pkg/services/pushbullet/pushbullet_test.go | 91 ++++++++++++++++-- pkg/services/services_test.go | 14 ++- pkg/util/jsonclient/interface.go | 10 ++ pkg/util/jsonclient/jsonclient.go | 51 ++++++++-- pkg/util/util.go | 8 ++ 8 files changed, 269 insertions(+), 147 deletions(-) create mode 100644 pkg/util/jsonclient/interface.go diff --git a/pkg/services/pushbullet/pushbullet.go b/pkg/services/pushbullet/pushbullet.go index 97d51c1e..0f2062b2 100644 --- a/pkg/services/pushbullet/pushbullet.go +++ b/pkg/services/pushbullet/pushbullet.go @@ -1,104 +1,72 @@ package pushbullet import ( - "bytes" "fmt" - "net/http" - "net/url" - "regexp" - + "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/jsonclient" + "net/url" +) + +const ( + pushesEndpoint = "https://api.pushbullet.com/v2/pushes" ) // Service providing Pushbullet as a notification service type Service struct { standard.Standard + client jsonclient.Client config *Config + pkr format.PropKeyResolver } // 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{} - if err := service.config.SetURL(configURL); err != nil { + service.pkr = format.NewPropKeyResolver(service.config) + if err := service.config.setURL(&service.pkr, configURL); err != nil { return err } + service.client = jsonclient.NewClient() + service.client.Headers().Set("Access-Token", service.config.Token) + return nil } -// Send ... +// Send a push notification via Pushbullet func (service *Service) Send(message string, params *types.Params) error { - config := service.config + config := *service.config + if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { + return err + } + for _, target := range config.Targets { - if err := doSend(config, target, message, params); err != nil { + if err := doSend(&config, target, message, service.client); err != nil { return err } } return nil } -func getTitle(params *types.Params) string { - title := "Shoutrrr notification" - if params != nil { - valParams := *params - title, ok := valParams["title"] - if !ok { - return title - } - } - return title -} +func doSend(config *Config, target string, message string, client jsonclient.Client) error { -func doSend(config *Config, target string, message string, params *types.Params) error { - targetType, err := getTargetType(target) - if err != nil { - return err - } + push := NewNotePush(message, config.Title) + push.SetTarget(target) - apiURL := serviceURL - json, _ := CreateJSONPayload(target, targetType, config, message, params) - client := &http.Client{} - req, _ := http.NewRequest("POST", apiURL, bytes.NewReader(json)) - req.Header.Add("Access-Token", config.Token) - req.Header.Add("Content-Type", "application/json") - - res, err := client.Do(req) - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("failed to send notification to service, response status code %s", res.Status) + response := PushResponse{} + if err := client.Post(pushesEndpoint, push, &response); err != nil { + errorResponse := ErrorResponse{} + if client.ErrorResponse(err, &errorResponse) { + return fmt.Errorf("API error: %v", errorResponse.Error.Message) + } + return fmt.Errorf("failed to push: %v", err) } - if err != nil { - return fmt.Errorf("error occurred while posting to pushbullet: %s", err.Error()) - } + // TODO: Look at response fields? return nil } - -func getTargetType(target string) (TargetType, error) { - matchesEmail, err := regexp.MatchString(`.*@.*\..*`, target) - - if matchesEmail && err == nil { - return EmailTarget, nil - } - - if len(target) > 0 && string(target[0]) == "#" { - return ChannelTarget, nil - } - - return DeviceTarget, nil -} - -// TargetType ... -type TargetType int - -const ( - // EmailTarget ... - EmailTarget TargetType = 1 - // ChannelTarget ... - ChannelTarget TargetType = 2 - // DeviceTarget ... - DeviceTarget TargetType = 3 -) diff --git a/pkg/services/pushbullet/pushbullet_config.go b/pkg/services/pushbullet/pushbullet_config.go index 1762bca7..2a7d95f8 100644 --- a/pkg/services/pushbullet/pushbullet_config.go +++ b/pkg/services/pushbullet/pushbullet_config.go @@ -3,6 +3,8 @@ package pushbullet import ( "errors" "fmt" + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" "net/url" "strings" @@ -14,62 +16,73 @@ type Config struct { standard.EnumlessConfig Targets []string `url:"path"` Token string `url:"host"` + Title string `key:"title" default:"Shoutrrr notification"` } // 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 { return &url.URL{ Host: config.Token, + Path: "/" + strings.Join(config.Targets, "/"), 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 { - splitBySlash := func(c rune) bool { - return c == '/' +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + path := url.Path + + if len(path) > 0 && path[0] == '/' { + // Remove initial slash to skip empty first target + path = path[1:] } - path := strings.FieldsFunc(url.Path, splitBySlash) if url.Fragment != "" { - path = append(path, fmt.Sprintf("#%s", url.Fragment)) - } - if len(path) == 0 { - path = []string{""} + path += fmt.Sprintf("/#%s", url.Fragment) } - config.Token = url.Host - config.Targets = path[0:] + targets := strings.Split(path, "/") - if err := validateToken(config.Token); err != nil { + token := url.Hostname() + if err := validateToken(token); err != nil { return err } - return nil -} + config.Token = token + config.Targets = targets -func validateToken(token string) error { - if err := tokenHasCorrectSize(token); err != nil { - return err + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return err + } } + return nil } -func tokenHasCorrectSize(token string) error { +func validateToken(token string) error { if len(token) != 34 { - return errors.New(string(TokenIncorrectSize)) + return ErrorTokenIncorrectSize } return nil } -//ErrorMessage for error events within the pushbullet service -type ErrorMessage string - const ( - serviceURL = "https://api.pushbullet.com/v2/pushes" //Scheme is the scheme part of the service configuration URL Scheme = "pushbullet" - //TokenIncorrectSize for the serviceURL - TokenIncorrectSize ErrorMessage = "Token has incorrect size" ) + +// ErrorTokenIncorrectSize is the error returned when the token size is incorrect +var ErrorTokenIncorrectSize = errors.New("token has incorrect size") diff --git a/pkg/services/pushbullet/pushbullet_json.go b/pkg/services/pushbullet/pushbullet_json.go index e590e964..6ab8a036 100644 --- a/pkg/services/pushbullet/pushbullet_json.go +++ b/pkg/services/pushbullet/pushbullet_json.go @@ -1,13 +1,11 @@ package pushbullet import ( - "encoding/json" - - "github.com/containrrr/shoutrrr/pkg/types" + "regexp" ) -// JSON used within the Slack service -type JSON struct { +// PushRequest ... +type PushRequest struct { Type string `json:"type"` Title string `json:"title"` Body string `json:"body"` @@ -17,39 +15,54 @@ type JSON struct { DeviceIden string `json:"device_iden"` } -// CreateJSONPayload compatible with the slack webhook api -func CreateJSONPayload(target string, targetType TargetType, config *Config, message string, params *types.Params) ([]byte, error) { - baseMessage := JSON{ - Type: "note", - Title: getTitle(params), - Body: message, - } - - switch targetType { - case EmailTarget: - return CreateEmailPayload(config, target, baseMessage) - case ChannelTarget: - return CreateChannelPayload(config, target, baseMessage) - case DeviceTarget: - return CreateDevicePayload(config, target, baseMessage) - } - return json.Marshal(baseMessage) +type PushResponse struct { + Active bool `json:"active"` + Body string `json:"body"` + Created float64 `json:"created"` + Direction string `json:"direction"` + Dismissed bool `json:"dismissed"` + Iden string `json:"iden"` + Modified float64 `json:"modified"` + ReceiverEmail string `json:"receiver_email"` + ReceiverEmailNormalized string `json:"receiver_email_normalized"` + ReceiverIden string `json:"receiver_iden"` + SenderEmail string `json:"sender_email"` + SenderEmailNormalized string `json:"sender_email_normalized"` + SenderIden string `json:"sender_iden"` + SenderName string `json:"sender_name"` + Title string `json:"title"` + Type string `json:"type"` } -//CreateChannelPayload from a base message -func CreateChannelPayload(config *Config, target string, partialPayload JSON) ([]byte, error) { - partialPayload.ChannelTag = target[1:] - return json.Marshal(partialPayload) +type ErrorResponse struct { + Error struct { + Cat string `json:"cat"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` } -//CreateDevicePayload from a base message -func CreateDevicePayload(config *Config, target string, partialPayload JSON) ([]byte, error) { - partialPayload.DeviceIden = target - return json.Marshal(partialPayload) +var emailPattern = regexp.MustCompile(`.*@.*\..*`) + +func (p *PushRequest) SetTarget(target string) { + if emailPattern.MatchString(target) { + p.Email = target + return + } + + if len(target) > 0 && string(target[0]) == "#" { + p.ChannelTag = target[1:] + return + } + + p.DeviceIden = target } -//CreateEmailPayload from a base message -func CreateEmailPayload(config *Config, target string, partialPayload JSON) ([]byte, error) { - partialPayload.Email = target - return json.Marshal(partialPayload) +// NewNotePush creates a new push request +func NewNotePush(message, title string) *PushRequest { + return &PushRequest{ + Type: "note", + Title: title, + Body: message, + } } diff --git a/pkg/services/pushbullet/pushbullet_test.go b/pkg/services/pushbullet/pushbullet_test.go index 4a69dff5..03c68529 100644 --- a/pkg/services/pushbullet/pushbullet_test.go +++ b/pkg/services/pushbullet/pushbullet_test.go @@ -1,8 +1,10 @@ package pushbullet_test import ( + "errors" . "github.com/containrrr/shoutrrr/pkg/services/pushbullet" "github.com/containrrr/shoutrrr/pkg/util" + "github.com/jarcoal/httpmock" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -36,8 +38,9 @@ var _ = Describe("the pushbullet service", func() { } serviceURL, _ := url.Parse(envPushbulletURL.String()) - service.Initialize(serviceURL, util.TestLogger()) - err := service.Send("This is an integration test message", nil) + err := service.Initialize(serviceURL, util.TestLogger()) + Expect(err).NotTo(HaveOccurred()) + err = service.Send("This is an integration test message", nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -58,22 +61,92 @@ var _ = Describe("the pushbullet service", func() { err := config.SetURL(pushbulletURL) Expect(err).NotTo(HaveOccurred()) - Expect(config.Targets[0]).To(Equal("test")) + Expect(config.Targets).To(HaveLen(1)) + Expect(config.Targets).To(ContainElements("test")) }) It("should set the channel from path", func() { - pushbulletURL, _ := url.Parse("pushbullet://tokentokentokentokentokentokentoke/#test") + pushbulletURL, _ := url.Parse("pushbullet://tokentokentokentokentokentokentoke/foo#bar") config := Config{} err := config.SetURL(pushbulletURL) Expect(err).NotTo(HaveOccurred()) - Expect(config.Targets[0]).To(Equal("#test")) + Expect(config.Targets).To(HaveLen(2)) + Expect(config.Targets).To(ContainElements("foo", "#bar")) + }) + }) + + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + testURL := "pushbullet://tokentokentokentokentokentokentoke/device?title=Great+News" + + config := &Config{} + err := config.SetURL(util.URLMust(testURL)) + Expect(err).NotTo(HaveOccurred(), "verifying") + + outputURL := config.GetURL() + Expect(outputURL.String()).To(Equal(testURL)) + }) }) }) + + Describe("building the payload", func() { + It("Email target should only populate one the correct field", func() { + push := PushRequest{} + push.SetTarget("iam@email.com") + Expect(push.Email).To(Equal("iam@email.com")) + Expect(push.DeviceIden).To(BeEmpty()) + Expect(push.ChannelTag).To(BeEmpty()) + }) + It("Device target should only populate one the correct field", func() { + push := PushRequest{} + push.SetTarget("device") + Expect(push.Email).To(BeEmpty()) + Expect(push.DeviceIden).To(Equal("device")) + Expect(push.ChannelTag).To(BeEmpty()) + }) + It("Channel target should only populate one the correct field", func() { + push := PushRequest{} + push.SetTarget("#channel") + Expect(push.Email).To(BeEmpty()) + Expect(push.DeviceIden).To(BeEmpty()) + Expect(push.ChannelTag).To(Equal("channel")) + }) + }) + + Describe("sending the payload", func() { + var err error + targetURL := "https://api.pushbullet.com/v2/pushes" + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should not report an error if the server accepts the payload", func() { + err = initService("pushbullet://tokentokentokentokentokentokentoke/test") + Expect(err).NotTo(HaveOccurred()) + + responder, _ := httpmock.NewJsonResponder(200, &PushResponse{}) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + It("should not panic if an error occurs when sending the payload", func() { + err = initService("pushbullet://tokentokentokentokentokentokentoke/test") + Expect(err).NotTo(HaveOccurred()) + + httpmock.RegisterResponder("POST", targetURL, httpmock.NewErrorResponder(errors.New(""))) + + err = service.Send("Message", nil) + Expect(err).To(HaveOccurred()) + }) + }) }) -func expectErrorMessageGivenURL(msg ErrorMessage, pushbulletURL *url.URL) { - err := service.Initialize(pushbulletURL, util.TestLogger()) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal(string(msg))) +func initService(rawURL string) error { + serviceURL, err := url.Parse(rawURL) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + return service.Initialize(serviceURL, util.TestLogger()) } diff --git a/pkg/services/services_test.go b/pkg/services/services_test.go index 05ad697b..2add3172 100644 --- a/pkg/services/services_test.go +++ b/pkg/services/services_test.go @@ -6,6 +6,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/types" "github.com/jarcoal/httpmock" "log" + "net/http" "testing" . "github.com/onsi/ginkgo" @@ -38,6 +39,10 @@ var serviceURLs = map[string]string{ "zulip": "zulip://mail:key@example.com/?stream=foo&topic=bar", } +var serviceResponses = map[string]string{ + "pushbullet": `{"created": 0}`, +} + var logger = log.New(GinkgoWriter, "Test", log.LstdFlags) var _ = Describe("services", func() { @@ -73,13 +78,12 @@ var _ = Describe("services", func() { } httpmock.Activate() + // Always return an "OK" result, as the http request isn't what is under test + respStatus := http.StatusOK if key == "discord" || key == "ifttt" { - // Always return a "No content" result, as the http request isn't what is under test - httpmock.RegisterNoResponder(httpmock.NewStringResponder(204, "")) - } else { - // Always return an "OK" result, as the http request isn't what is under test - httpmock.RegisterNoResponder(httpmock.NewStringResponder(200, "")) + respStatus = http.StatusNoContent } + httpmock.RegisterNoResponder(httpmock.NewStringResponder(respStatus, serviceResponses[key])) service, err := serviceRouter.Locate(configURL) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/util/jsonclient/interface.go b/pkg/util/jsonclient/interface.go new file mode 100644 index 00000000..7316026c --- /dev/null +++ b/pkg/util/jsonclient/interface.go @@ -0,0 +1,10 @@ +package jsonclient + +import "net/http" + +type Client interface { + Get(url string, response interface{}) error + Post(url string, request interface{}, response interface{}) error + Headers() http.Header + ErrorResponse(err error, response interface{}) bool +} diff --git a/pkg/util/jsonclient/jsonclient.go b/pkg/util/jsonclient/jsonclient.go index a6396d0f..3e7fbe5c 100644 --- a/pkg/util/jsonclient/jsonclient.go +++ b/pkg/util/jsonclient/jsonclient.go @@ -12,7 +12,7 @@ import ( const ContentType = "application/json" // DefaultClient is the singleton instance of jsonclient using http.DefaultClient -var DefaultClient = &Client{HTTPClient: http.DefaultClient} +var DefaultClient = NewClient() // Get fetches url using GET and unmarshals into the passed response using DefaultClient func Get(url string, response interface{}) error { @@ -25,13 +25,29 @@ func Post(url string, request interface{}, response interface{}) error { } // Client is a JSON wrapper around http.Client -type Client struct { - HTTPClient *http.Client +type client struct { + httpClient *http.Client + headers http.Header + indent string +} + +func NewClient() Client { + return &client{ + httpClient: http.DefaultClient, + headers: http.Header{ + "Content-Type": []string{ContentType}, + }, + } +} + +// Headers return the default headers for requests +func (c *client) Headers() http.Header { + return c.headers } // Get fetches url using GET and unmarshals into the passed response -func (c *Client) Get(url string, response interface{}) error { - res, err := c.HTTPClient.Get(url) +func (c *client) Get(url string, response interface{}) error { + res, err := c.httpClient.Get(url) if err != nil { return err } @@ -40,18 +56,26 @@ func (c *Client) Get(url string, response interface{}) error { } // Post sends request as JSON and unmarshals the response JSON into the supplied struct -func (c *Client) Post(url string, request interface{}, response interface{}) error { - +func (c *client) Post(url string, request interface{}, response interface{}) error { var err error var body []byte - body, err = json.Marshal(request) + body, err = json.MarshalIndent(request, "", c.indent) if err != nil { return fmt.Errorf("error creating payload: %v", err) } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("error creating request: %v", err) + } + + for key, val := range c.headers { + req.Header.Set(key, val[0]) + } + var res *http.Response - res, err = c.HTTPClient.Post(url, ContentType, bytes.NewReader(body)) + res, err = c.httpClient.Do(req) if err != nil { return fmt.Errorf("error sending payload: %v", err) } @@ -59,6 +83,15 @@ func (c *Client) Post(url string, request interface{}, response interface{}) err return parseResponse(res, response) } +func (c *client) ErrorResponse(err error, response interface{}) bool { + jerr, isJsonError := err.(Error) + if !isJsonError { + return false + } + + return json.Unmarshal([]byte(jerr.Body), response) == nil +} + func parseResponse(res *http.Response, response interface{}) error { defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) diff --git a/pkg/util/util.go b/pkg/util/util.go index 32bb2354..a47b1ed6 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,9 +1,11 @@ package util import ( + "github.com/onsi/gomega" "io/ioutil" "log" "math" + "net/url" "github.com/onsi/ginkgo" ) @@ -36,3 +38,9 @@ func TestLogger() *log.Logger { // DiscardLogger is a logger that discards any output written to it var DiscardLogger = log.New(ioutil.Discard, "", 0) + +func URLMust(rawURL string) *url.URL { + parsed, err := url.Parse(rawURL) + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) + return parsed +} From 87e10e15263298f61635cd40845eab34167c9ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 4 Jul 2021 02:51:10 +0200 Subject: [PATCH 06/22] ci: fix mkdocs material clone --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index be9bf534..df9fa42f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,7 +31,7 @@ jobs: mkdocs \ mike \ md-toc \ - git+https://$MKDOCSMAT_TOKEN@github.com/squidfunk/mkdocs-material-insiders.git + git+https://${MKDOCSMAT_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git - name: Generate service config docs run: bash generate-service-config-docs.sh From 1fd438b3aab780823bda2f43f5ebd15c46170ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 4 Jul 2021 02:58:57 +0200 Subject: [PATCH 07/22] ci: add git settings for mkdocs --- .github/workflows/docs.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index df9fa42f..385bb468 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,11 +42,17 @@ jobs: echo "DOCS_EDIT_URI=/edit/main/docs/" >> $GITHUB_ENV echo "DOCS_VERSION=dev" >> $GITHUB_ENV - - name: Update env (latest) + - name: Update env for docs (latest) if: ${{ github.ref == 'refs/heads/latest' }} run: | echo "DOCS_EDIT_URI=/edit/latest/docs/" >> $GITHUB_ENV echo "DOCS_VERSION=latest" >> $GITHUB_ENV + - name: Setup Git + run: | + git config --global user.name "${GITHUB_ACTOR}" + git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Deploy docs run: mike deploy --push $DOCS_VERSION From 892671adecbcc52c1c235b8018b7d351095a1c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 4 Jul 2021 03:06:13 +0200 Subject: [PATCH 08/22] ci(docs): set repo token creds --- .github/workflows/docs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 385bb468..87cdc5a0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,9 +49,12 @@ jobs: echo "DOCS_VERSION=latest" >> $GITHUB_ENV - name: Setup Git + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git config --global user.name "${GITHUB_ACTOR}" git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git remote --set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - name: Deploy docs From 64eda4a9c31c67a5ac515e18c88e08a44b00e767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 4 Jul 2021 03:08:26 +0200 Subject: [PATCH 09/22] ci(docs): fix git cli syntax --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 87cdc5a0..6cba7252 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -54,7 +54,7 @@ jobs: run: | git config --global user.name "${GITHUB_ACTOR}" git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" - git remote --set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - name: Deploy docs From 8018a476b557f0d403b6d2cb1a4ca4ad37a56112 Mon Sep 17 00:00:00 2001 From: Sajad Parra Date: Wed, 7 Jul 2021 15:44:19 +0530 Subject: [PATCH 10/22] feat(slack): add thread/reply support (#182) --- pkg/services/slack/slack_config.go | 14 ++++++++------ pkg/services/slack/slack_json.go | 2 ++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pkg/services/slack/slack_config.go b/pkg/services/slack/slack_config.go index 844e7a8b..9eb683cf 100644 --- a/pkg/services/slack/slack_config.go +++ b/pkg/services/slack/slack_config.go @@ -2,20 +2,22 @@ package slack import ( "fmt" + "net/url" + "strings" + "github.com/containrrr/shoutrrr/pkg/format" "github.com/containrrr/shoutrrr/pkg/services/standard" "github.com/containrrr/shoutrrr/pkg/types" - "net/url" - "strings" ) // Config for the slack service type Config struct { standard.EnumlessConfig - BotName string `default:"" optional:"" url:"user" desc:"Bot name (uses default if empty)"` - Token []string `desc:"Webhook token parts" url:"host,path1,path2"` - Color string `key:"color" optional:"" desc:"Message left-hand border color"` - Title string `key:"title" optional:"" desc:"Prepended text above the message"` + BotName string `default:"" optional:"" url:"user" desc:"Bot name (uses default if empty)"` + Token []string `desc:"Webhook token parts" url:"host,path1,path2"` + Color string `key:"color" optional:"" desc:"Message left-hand border color"` + Title string `key:"title" optional:"" desc:"Prepended text above the message"` + ThreadTS string `key:"thread_ts" optional:"" desc:"ts value of the parent message (to send message as reply in thread)"` } // GetURL returns a URL representation of it's current field values diff --git a/pkg/services/slack/slack_json.go b/pkg/services/slack/slack_json.go index 2e3502c4..88b21677 100644 --- a/pkg/services/slack/slack_json.go +++ b/pkg/services/slack/slack_json.go @@ -11,6 +11,7 @@ type JSON struct { BotName string `json:"username,omitempty"` Blocks []block `json:"blocks,omitempty"` Attachments []attachment `json:"attachments,omitempty"` + ThreadTS string `json:"thread_ts,omitempty"` } type block struct { @@ -52,6 +53,7 @@ func CreateJSONPayload(config *Config, message string) ([]byte, error) { return json.Marshal( JSON{ + ThreadTS: config.ThreadTS, Text: config.Title, BotName: config.BotName, Attachments: atts, From 5d12d755da7adc596ce45560429a25dfeb63ca9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sun, 18 Jul 2021 16:54:31 +0200 Subject: [PATCH 11/22] fix(logger): avoid mutating passed params (#184) this fixes an old bug where the logger, when combined with other services would add the param `message` to other services' `Send()` (which they usually don't like) also adds tests for this and for normal operation --- pkg/services/logger/logger.go | 17 ++++--- pkg/services/logger/logger_suite_test.go | 65 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 pkg/services/logger/logger_suite_test.go diff --git a/pkg/services/logger/logger.go b/pkg/services/logger/logger.go index 9246db84..4fe71c80 100644 --- a/pkg/services/logger/logger.go +++ b/pkg/services/logger/logger.go @@ -17,18 +17,21 @@ type Service struct { // Send a notification message to log func (service *Service) Send(message string, params *types.Params) error { - if params == nil { - params = &types.Params{} + data := types.Params{} + if params != nil { + for key, value := range *params { + data[key] = value + } } - (*params)["message"] = message - return service.doSend(params) + data["message"] = message + return service.doSend(data) } -func (service *Service) doSend(params *types.Params) error { - msg := (*params)["message"] +func (service *Service) doSend(data types.Params) error { + msg := data["message"] if tpl, found := service.GetTemplate("message"); found { wc := &strings.Builder{} - if err := tpl.Execute(wc, params); err != nil { + if err := tpl.Execute(wc, data); err != nil { return fmt.Errorf("failed to write template to log: %s", err) } msg = wc.String() diff --git a/pkg/services/logger/logger_suite_test.go b/pkg/services/logger/logger_suite_test.go new file mode 100644 index 00000000..fd08a7c9 --- /dev/null +++ b/pkg/services/logger/logger_suite_test.go @@ -0,0 +1,65 @@ +package logger_test + +import ( + unit "github.com/containrrr/shoutrrr/pkg/services/logger" + "github.com/containrrr/shoutrrr/pkg/types" + + "github.com/containrrr/shoutrrr/pkg/util" + "github.com/onsi/gomega/gbytes" + + "log" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestLogger(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Logger Suite") +} + +var _ = Describe("the logger service", func() { + + When("sending a notification", func() { + + It("should output the message to the log", func() { + logbuf := gbytes.NewBuffer() + service := &unit.Service{} + _ = service.Initialize(util.URLMust(`logger://`), log.New(logbuf, "", 0)) + + err := service.Send(`Failed - Requires Toaster Repair Level 10`, nil) + Expect(err).NotTo(HaveOccurred()) + + Eventually(logbuf).Should(gbytes.Say("Failed - Requires Toaster Repair Level 10")) + }) + + It("should not mutate the passed params", func() { + service := &unit.Service{} + _ = service.Initialize(util.URLMust(`logger://`), nil) + params := types.Params{} + err := service.Send(`Failed - Requires Toaster Repair Level 10`, ¶ms) + Expect(err).NotTo(HaveOccurred()) + + Expect(params).To(BeEmpty()) + }) + + When("when a template has been added", func() { + It("should render template with params", func() { + logbuf := gbytes.NewBuffer() + service := &unit.Service{} + _ = service.Initialize(util.URLMust(`logger://`), log.New(logbuf, "", 0)) + err := service.SetTemplateString(`message`, `{{.level}}: {{.message}}`) + Expect(err).NotTo(HaveOccurred()) + + params := types.Params{ + "level": "warning", + } + err = service.Send(`Requires Toaster Repair Level 10`, ¶ms) + Expect(err).NotTo(HaveOccurred()) + + Eventually(logbuf).Should(gbytes.Say("warning: Requires Toaster Repair Level 10")) + }) + }) + }) +}) From 57b43a36096159d5e91c5ad4884d28a9d3f0e52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 27 Jul 2021 15:10:41 +0200 Subject: [PATCH 12/22] feat(slack): add bot API support (#179) --- .../slack/app-api-channel-details-id.png | Bin 0 -> 5935 bytes .../guides/slack/app-api-copy-oauth-token.png | Bin 0 -> 22308 bytes docs/guides/slack/app-api-oauth-menu.png | Bin 0 -> 16321 bytes docs/guides/slack/app-api-select-channel.png | Bin 0 -> 19207 bytes docs/guides/slack/index.md | 47 ++++ docs/index.md | 16 +- docs/services/slack.md | 31 +-- docs/stylesheets/extra.css | 30 +++ go.mod | 1 + go.sum | 34 --- mkdocs.yml | 3 + pkg/services/slack/slack.go | 77 ++++-- pkg/services/slack/slack_config.go | 39 ++- pkg/services/slack/slack_errors.go | 24 +- pkg/services/slack/slack_json.go | 60 ++++- pkg/services/slack/slack_test.go | 227 ++++++++++++------ pkg/services/slack/slack_token.go | 143 ++++++++--- pkg/util/generator/generator_common.go | 1 + pkg/util/generator/generator_test.go | 93 +++---- 19 files changed, 547 insertions(+), 279 deletions(-) create mode 100644 docs/guides/slack/app-api-channel-details-id.png create mode 100644 docs/guides/slack/app-api-copy-oauth-token.png create mode 100644 docs/guides/slack/app-api-oauth-menu.png create mode 100644 docs/guides/slack/app-api-select-channel.png create mode 100644 docs/guides/slack/index.md create mode 100644 docs/stylesheets/extra.css diff --git a/docs/guides/slack/app-api-channel-details-id.png b/docs/guides/slack/app-api-channel-details-id.png new file mode 100644 index 0000000000000000000000000000000000000000..075b879f797f72bc29fbcb23b96e3a1405264c75 GIT binary patch literal 5935 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 JSKhS=|34k5W*Pth literal 0 HcmV?d00001 diff --git a/docs/guides/slack/app-api-copy-oauth-token.png b/docs/guides/slack/app-api-copy-oauth-token.png new file mode 100644 index 0000000000000000000000000000000000000000..135d01104f2ff30fd88c7f11a850a92d12871f25 GIT binary patch literal 22308 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 YfJD*tydKAo?q>nq)-}?p(0&yDKh!W)3IG5A literal 0 HcmV?d00001 diff --git a/docs/guides/slack/app-api-oauth-menu.png b/docs/guides/slack/app-api-oauth-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..9df1f9bfa69ef0283e8b0cc212bd306f8f5a88b1 GIT binary patch literal 16321 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 B+tdI6 literal 0 HcmV?d00001 diff --git a/docs/guides/slack/index.md b/docs/guides/slack/index.md new file mode 100644 index 00000000..bba50231 --- /dev/null +++ b/docs/guides/slack/index.md @@ -0,0 +1,47 @@ +# Slack Guides + +Guides for setting up the [Slack](../../services/slack.md) service + +## Getting a token + +To enable all features, either the Legacy Webhook- (deprecated and might stop working) or the bot API tokens needs to +be used. Only use the non-legacy Webhook if you don't need to customize the bot name or icon. + +### Bot API (preferred) + +1. Create a new App for your bot using the [Basic app setup guide](https://api.slack.com/authentication/basics) +2. Install the App into your workspace ([slack docs](https://api.slack.com/authentication/basics#installing)). +3. From [Apps](https://api.slack.com/apps), select your new App and go to **Oauth & Permissions** +
Slack app management menu screenshot
+4. Copy the Bot User OAuth Token +
Copy OAuth token screenshot
+ +!!! example + Given the API token +
xoxb-123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N
+ and the channel ID `C001CH4NN3L` (obtained by using the [guide below](#getting_the_channel_id)), the Shoutrrr URL + should look like this: +
slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C001CH4NN3L
+ +### Webhook tokens + +Get a Webhook URL using the legacy [WebHooks Integration](https://slack.com/apps/new/A0F7XDUAZ-incoming-webhooks), +or by using the [Getting started with Incoming Webhooks](https://api.slack.com/messaging/webhooks#getting_started) guide and +replace the initial `https://hooks.slack.com/services/` part of the webhook URL with `slack://hook:` to get your Shoutrrr URL. + +!!! info "Slack Webhook URL" + https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX + +!!! info "Shoutrrr URL" + slack://hook:T00000000-B00000000-XXXXXXXXXXXXXXXXXXXXXXXX@webhook + +## Getting the Channel ID + +!!! note "" + Only needed for API token. Use `webhook` as the channel for webhook tokens. + +1. In the channel you wish to post to, open **Channel Details** by clicking on the channel title. +
Opening channel details screenshot
+ +2. Copy the Channel ID from the bottom of the popup and append it to your Shoutrrr URL +
Copy channel ID screenshot
\ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 7c5741a6..2a472e53 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,27 +9,27 @@ Notification library for gophers and their furry friends.
Heavily inspired by
caronc/apprise.

-

+

- github actions workflow status + github actions workflow status - codecov + codecov - Codacy Badge + Codacy Badge - report card + report card - go.dev reference + go.dev reference - github code size in bytes + github code size in bytes - license + license

diff --git a/docs/services/slack.md b/docs/services/slack.md index c011f34c..9cb7e230 100644 --- a/docs/services/slack.md +++ b/docs/services/slack.md @@ -1,22 +1,21 @@ # Slack -The slack notification service uses [Slack Webhook](https://api.slack.com/messaging/webhooks)s to send messages. -Follow the [Getting started with Incoming Webhooks](https://api.slack.com/messaging/webhooks#getting_started) guide and -replace the initial `https://hooks.slack.com/services/` part of the webhook URL with `slack://` to get your Shoutrrr URL. +!!! attention "New URL format" + The URL format for Slack has been changed to allow for API- as well as webhook tokens. + Using the old format (`slack://xxxx/yyyy/zzzz`) will still work as before and will automatically be upgraded to + the new format when used. -*Slack Webhook URL:* +The Slack notification service uses either [Slack Webhooks](https://api.slack.com/messaging/webhooks) or the +[Bot API](https://api.slack.com/methods/chat.postMessage) to send messages. -!!! info "" - https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX +See the [guides](../guides/slack/index.md) for information on how to get your *token* and *channel*. -Shoutrrr URL: -!!! info "" - slack://T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX +## URL Format +!!! note "" + Note that the token uses a prefix to determine the type, usually either `hook` (for webhooks) or `xoxb` (for bot API). -## URL Format - --8<-- "docs/services/slack/config.md" !!! info "Color format" @@ -26,8 +25,12 @@ Shoutrrr URL: ## Examples -!!! example - All fields set: +!!! example "Bot API" + ```uri + slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C001CH4NN3L?color=good&title=Great+News&icon=man-scientist&botname=Shoutrrrbot + ``` + +!!! example "Webhook" ```uri - slack://ShoutrrrBot@T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX?color=good&title=Great+News + slack://hook:WNA3PBYV6-F20DUQND3RQ-Webc4MAvoacrpPakR8phF0zi@webhook?color=good&title=Great+News&icon=man-scientist&botname=Shoutrrrbot ``` \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..83c0653f --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,30 @@ + +.md-typeset li img { + display: inline-block; +} + +.md-typeset figure { + background: var(--md-code-bg-color); + display: block; + width: 100%; +} + +.md-typeset figure img { + box-shadow: 2px 2px 4px #00000080; + padding: 3px; + background: var(--md-code-bg-color); +} + + +.md-typeset li img:last-child { + margin: 10px 0; +} + +.badges img { + height: 20px; + max-width: 100%; + display: inline-block; + padding: 0; + background: transparent; + border-radius: 3px; +} \ No newline at end of file diff --git a/go.mod b/go.mod index a85a4782..71e60926 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.1.5 // indirect github.com/jarcoal/httpmock v1.0.4 github.com/klauspost/compress v1.11.7 // indirect + github.com/mattn/go-colorable v0.1.8 github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/nxadm/tail v1.4.6 // indirect github.com/onsi/ginkgo v1.14.2 diff --git a/go.sum b/go.sum index e1969ea2..72f3b576 100644 --- a/go.sum +++ b/go.sum @@ -35,7 +35,6 @@ github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -69,9 +68,7 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= @@ -85,7 +82,6 @@ github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= @@ -93,7 +89,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.5 h1:kxhtnfFVi+rYdOALN0B3k9UT86zVJKfBimRaciULW4I= github.com/google/uuid v1.1.5/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -107,7 +102,6 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -121,12 +115,10 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -137,7 +129,6 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -151,14 +142,12 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -168,13 +157,11 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.6 h1:11TGpSHY7Esh/i/qnq02Jo5oVrI1Gue8Slbq0ujPZFQ= github.com/nxadm/tail v1.4.6/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M= @@ -183,7 +170,6 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= @@ -204,7 +190,6 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= -github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= @@ -213,33 +198,27 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.7 h1:FfTH+vuMXOas8jmfb5/M7dzEYx7LpcLb7a0LPe34uOU= github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.6.3 h1:pDDu1OyEDTKzpJwdq4TiuLyMsUgRa/BT5cn5O62NoHs= github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -261,7 +240,6 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -274,7 +252,6 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0= @@ -293,9 +270,7 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -312,7 +287,6 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -325,7 +299,6 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -344,14 +317,11 @@ google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyz google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= -gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -360,11 +330,8 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -374,7 +341,6 @@ gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81 gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8= -nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg= nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY= nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k= nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/mkdocs.yml b/mkdocs.yml index 78e6021b..c81d9f19 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ extra: generator: false extra_css: - stylesheets/theme.css + - stylesheets/extra.css markdown_extensions: - toc: permalink: True @@ -49,6 +50,8 @@ nav: - Telegram: 'services/telegram.md' - Zulip Chat: 'services/zulip.md' - Generic Webhook: 'services/generic.md' + - Guides: + - Slack: 'guides/slack/index.md' - Advanced usage: - Proxy: 'proxy.md' diff --git a/pkg/services/slack/slack.go b/pkg/services/slack/slack.go index 45e5aed6..926cac3c 100644 --- a/pkg/services/slack/slack.go +++ b/pkg/services/slack/slack.go @@ -2,11 +2,13 @@ package slack import ( "bytes" + "encoding/json" "fmt" "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/util/jsonclient" + "io/ioutil" "net/http" "net/url" - "strings" "github.com/containrrr/shoutrrr/pkg/services/standard" "github.com/containrrr/shoutrrr/pkg/types" @@ -20,7 +22,7 @@ type Service struct { } const ( - apiURL = "https://hooks.slack.com/services" + apiPostMessage = "https://slack.com/api/chat.postMessage" ) // Send a notification message to Slack @@ -31,11 +33,20 @@ func (service *Service) Send(message string, params *types.Params) error { return err } - if err := ValidateToken(config.Token); err != nil { - return err + payload := CreateJSONPayload(config, message) + + var err error + if config.Token.IsAPIToken() { + err = service.sendAPI(config, payload) + } else { + err = service.sendWebhook(config, payload) + } + + if err != nil { + return fmt.Errorf("failed to send slack notification: %v", err) } - return service.doSend(config, message) + return nil } // Initialize loads ServiceConfig from configURL and sets logger for this Service @@ -43,33 +54,57 @@ func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) e service.Logger.SetLogger(logger) service.config = &Config{} service.pkr = format.NewPropKeyResolver(service.config) + return service.config.setURL(&service.pkr, configURL) + } -func (service *Service) doSend(config *Config, message string) error { - postURL := service.getURL(config) - payload, err := CreateJSONPayload(config, message) +func (service *Service) sendAPI(config *Config, payload interface{}) error { + response := APIResponse{} + jsonClient := jsonclient.NewClient() + jsonClient.Headers().Set("Authorization", config.Token.Authorization()) - var res *http.Response - if err == nil { - res, err = http.Post(postURL, "application/json", bytes.NewBuffer(payload)) - } - - if res == nil && err == nil { - err = fmt.Errorf("unknown error") + if err := jsonClient.Post(apiPostMessage, payload, &response); err != nil { + return err } - if err == nil && res.StatusCode != http.StatusOK { - err = fmt.Errorf("response status code %s", res.Status) + if !response.Ok { + if response.Error != "" { + return fmt.Errorf("api response: %v", response.Error) + } + return fmt.Errorf("unknown error") } - if err != nil { - return fmt.Errorf("failed to send slack notification: %v", err) + if response.Warning != "" { + service.Logger.Logf("Slack API warning: %q", response.Warning) } return nil } -func (service *Service) getURL(config *Config) string { - return fmt.Sprintf("%s/%s", apiURL, strings.Join(config.Token, "/")) +func (service *Service) sendWebhook(config *Config, payload interface{}) error { + payloadBytes, err := json.Marshal(payload) + var res *http.Response + res, err = http.Post(config.Token.WebhookURL(), jsonclient.ContentType, bytes.NewBuffer(payloadBytes)) + + if err != nil { + return fmt.Errorf("failed to invoke webhook: %v", err) + } + defer res.Body.Close() + resBytes, _ := ioutil.ReadAll(res.Body) + response := string(resBytes) + + switch response { + case "": + if res.StatusCode != http.StatusOK { + return fmt.Errorf("webhook status: %v", res.Status) + } + // Treat status 200 as no error regardless of actual content + fallthrough + case "ok": + return nil + default: + return fmt.Errorf("webhook response: %v", response) + } + } diff --git a/pkg/services/slack/slack_config.go b/pkg/services/slack/slack_config.go index 9eb683cf..76e81a22 100644 --- a/pkg/services/slack/slack_config.go +++ b/pkg/services/slack/slack_config.go @@ -1,9 +1,7 @@ package slack import ( - "fmt" "net/url" - "strings" "github.com/containrrr/shoutrrr/pkg/format" "github.com/containrrr/shoutrrr/pkg/services/standard" @@ -13,10 +11,12 @@ import ( // Config for the slack service type Config struct { standard.EnumlessConfig - BotName string `default:"" optional:"" url:"user" desc:"Bot name (uses default if empty)"` - Token []string `desc:"Webhook token parts" url:"host,path1,path2"` - Color string `key:"color" optional:"" desc:"Message left-hand border color"` - Title string `key:"title" optional:"" desc:"Prepended text above the message"` + BotName string `optional:"uses bot default" key:"botname,username" desc:"Bot name"` + Icon string `key:"icon,icon_emoji,icon_url" default:"" optional:"" desc:"Use emoji or URL as icon (based on presence of http(s):// prefix)"` + Token Token `desc:"API Bot token" url:"user,pass"` + Color string `key:"color" optional:"default border color" desc:"Message left-hand border color"` + Title string `key:"title" optional:"omitted" desc:"Prepended text above the message"` + Channel string `url:"host" desc:"Channel to send messages to in Cxxxxxxxxxx format"` ThreadTS string `key:"thread_ts" optional:"" desc:"ts value of the parent message (to send message as reply in thread)"` } @@ -34,9 +34,8 @@ func (config *Config) SetURL(url *url.URL) error { func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { return &url.URL{ - User: url.User(config.BotName), - Host: config.Token[0], - Path: fmt.Sprintf("/%s/%s", config.Token[1], config.Token[2]), + User: config.Token.UserInfo(), + Host: config.Channel, Scheme: Scheme, ForceQuery: false, RawQuery: format.BuildQuery(resolver), @@ -45,21 +44,21 @@ func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error { - botName := serviceURL.User.Username() + var token string + var err error - host := serviceURL.Hostname() + if len(serviceURL.Path) > 1 { + // Reading legacy config URL format + token = serviceURL.Hostname() + serviceURL.Path - token := strings.Split(serviceURL.Path, "/") - token[0] = host - - if len(token) < 2 { - token = []string{"", "", ""} + config.Channel = "webhook" + config.BotName = serviceURL.User.Username() + } else { + token = serviceURL.User.String() + config.Channel = serviceURL.Hostname() } - config.BotName = botName - config.Token = token - - if err := ValidateToken(config.Token); err != nil { + if err = config.Token.SetFromProp(token); err != nil { return err } diff --git a/pkg/services/slack/slack_errors.go b/pkg/services/slack/slack_errors.go index c033947e..6adbc49a 100644 --- a/pkg/services/slack/slack_errors.go +++ b/pkg/services/slack/slack_errors.go @@ -1,21 +1,11 @@ package slack -// ErrorMessage for error events within the slack service -type ErrorMessage string +import "errors" -const ( - // TokenAMissing from the service URL - TokenAMissing ErrorMessage = "first part of the API token is missing" - // TokenBMissing from the service URL - TokenBMissing ErrorMessage = "second part of the API token is missing" - // TokenCMissing from the service URL - TokenCMissing ErrorMessage = "third part of the API token is missing." - // TokenAMalformed inthe service URL - TokenAMalformed ErrorMessage = "first part of the API token is malformed" - // TokenBMalformed inthe service URL - TokenBMalformed ErrorMessage = "second part of the API token is malformed" - // TokenCMalformed inthe service URL - TokenCMalformed ErrorMessage = "third part of the API token is malformed" - // NotEnoughArguments provided in the service URL - NotEnoughArguments ErrorMessage = "the apiURL does not include enough arguments" +var ( + // ErrorInvalidToken is returned whenever the specified token does not match any known formats + ErrorInvalidToken = errors.New("invalid slack token format") + + // ErrorMismatchedTokenSeparators is returned if the token uses different separators between parts (of the recognized `/-,`) + ErrorMismatchedTokenSeparators = errors.New("invalid webhook token format") ) diff --git a/pkg/services/slack/slack_json.go b/pkg/services/slack/slack_json.go index 88b21677..103a4280 100644 --- a/pkg/services/slack/slack_json.go +++ b/pkg/services/slack/slack_json.go @@ -1,17 +1,36 @@ package slack import ( - "encoding/json" + "regexp" "strings" ) -// JSON used within the Slack service -type JSON struct { +// MessagePayload used within the Slack service +type MessagePayload struct { Text string `json:"text"` BotName string `json:"username,omitempty"` Blocks []block `json:"blocks,omitempty"` Attachments []attachment `json:"attachments,omitempty"` ThreadTS string `json:"thread_ts,omitempty"` + Channel string `json:"channel,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` +} + +var iconURLPattern = regexp.MustCompile(`https?://`) + +// SetIcon sets the appropriate icon field in the payload based on whether the input is a URL or not +func (p *MessagePayload) SetIcon(icon string) { + p.IconURL = "" + p.IconEmoji = "" + + if icon != "" { + if iconURLPattern.MatchString(icon) { + p.IconURL = icon + } else { + p.IconEmoji = icon + } + } } type block struct { @@ -40,8 +59,18 @@ type legacyField struct { Short bool `json:"short,omitempty"` } -// CreateJSONPayload compatible with the slack webhook api -func CreateJSONPayload(config *Config, message string) ([]byte, error) { +// APIResponse is the default generic response message sent from the API +type APIResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Warning string `json:"warning"` + MetaData struct { + Warnings []string `json:"warnings"` + } `json:"response_metadata"` +} + +// CreateJSONPayload compatible with the slack post message API +func CreateJSONPayload(config *Config, message string) interface{} { var atts []attachment for _, line := range strings.Split(message, "\n") { @@ -51,11 +80,18 @@ func CreateJSONPayload(config *Config, message string) ([]byte, error) { }) } - return json.Marshal( - JSON{ - ThreadTS: config.ThreadTS, - Text: config.Title, - BotName: config.BotName, - Attachments: atts, - }) + payload := MessagePayload{ + ThreadTS: config.ThreadTS, + Text: config.Title, + BotName: config.BotName, + Attachments: atts, + } + + payload.SetIcon(config.Icon) + + if config.Channel != "webhook" { + payload.Channel = config.Channel + } + + return payload } diff --git a/pkg/services/slack/slack_test.go b/pkg/services/slack/slack_test.go index e855fab2..aee8a158 100644 --- a/pkg/services/slack/slack_test.go +++ b/pkg/services/slack/slack_test.go @@ -13,9 +13,11 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + gomegaformat "github.com/onsi/gomega/format" ) func TestSlack(t *testing.T) { + gomegaformat.CharactersAroundMismatchToInclude = 20 RegisterFailHandler(Fail) RunSpecs(t, "Shoutrrr Slack Suite") } @@ -49,76 +51,62 @@ var _ = Describe("the slack service", func() { }) }) + // xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N + When("given a token with a malformed part", func() { It("should return an error if part A is not 9 letters", func() { - slackURL, err := url.Parse("slack://lol@12345678/123456789/123456789123456789123456") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenAMalformed, - slackURL, - ) + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://lol@12345678/123456789/123456789123456789123456") }) It("should return an error if part B is not 9 letters", func() { - slackURL, err := url.Parse("slack://lol@123456789/12345678/123456789123456789123456") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenBMalformed, - slackURL, - ) + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://lol@123456789/12345678/123456789123456789123456") }) It("should return an error if part C is not 24 letters", func() { - slackURL, err := url.Parse("slack://123456789/123456789/12345678912345678912345") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenCMalformed, - slackURL, - ) + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://123456789/123456789/12345678912345678912345") }) }) When("given a token missing a part", func() { It("should return an error if the missing part is A", func() { - slackURL, err := url.Parse("slack://lol@/123456789/123456789123456789123456") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenAMissing, - slackURL, - ) + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://lol@/123456789/123456789123456789123456") }) It("should return an error if the missing part is B", func() { - slackURL, err := url.Parse("slack://lol@123456789//123456789") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenBMissing, - slackURL, - ) - + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://lol@123456789//123456789") }) It("should return an error if the missing part is C", func() { - slackURL, err := url.Parse("slack://lol@123456789/123456789/") - Expect(err).NotTo(HaveOccurred()) - expectErrorMessageGivenURL( - TokenCMissing, - slackURL, - ) + expectErrorMessageGivenURL(ErrorInvalidToken, "slack://lol@123456789/123456789/") }) }) Describe("the slack config", func() { When("parsing the configuration URL", func() { - It("should be identical after de-/serialization", func() { - testURL := "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456?color=3f00fe&title=Test+title" - - url, err := url.Parse(testURL) - Expect(err).NotTo(HaveOccurred(), "parsing") + When("given a config using the legacy format", func() { + It("should be converted to the new format after de-/serialization", func() { + oldURL := "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456?color=3f00fe&title=Test+title" + newURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title" - config := &Config{} - err = config.SetURL(url) - Expect(err).NotTo(HaveOccurred(), "verifying") + config := &Config{} + err := config.SetURL(urlMust(oldURL)) + Expect(err).NotTo(HaveOccurred(), "verifying") - outputURL := config.GetURL() - Expect(outputURL.String()).To(Equal(testURL)) + Expect(config.GetURL().String()).To(Equal(newURL)) + }) }) }) + When("the URL contains an invalid property", func() { + testURL := urlMust("slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?bass=dirty") + err := (&Config{}).SetURL(testURL) + Expect(err).To(HaveOccurred()) + }) + It("should be identical after de-/serialization", func() { + testURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title" + + config := &Config{} + err := config.SetURL(urlMust(testURL)) + Expect(err).NotTo(HaveOccurred(), "verifying") + + outputURL := config.GetURL() + Expect(outputURL.String()).To(Equal(testURL)) + + }) When("generating a config object", func() { It("should use the default botname if the argument list contains three strings", func() { slackURL, _ := url.Parse("slack://AAAAAAAAA/BBBBBBBBB/123456789123456789123456") @@ -141,43 +129,134 @@ var _ = Describe("the slack service", func() { Expect(configError).To(HaveOccurred()) }) }) + When("getting credentials from token", func() { + It("should return a valid webhook URL for the given token", func() { + token := tokenMust("AAAAAAAAA/BBBBBBBBB/123456789123456789123456") + expected := "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" + Expect(token.WebhookURL()).To(Equal(expected)) + }) + It("should return a valid authorization header value for the given token", func() { + token := tokenMust("xoxb:AAAAAAAAA-BBBBBBBBB-123456789123456789123456") + expected := "Bearer xoxb-AAAAAAAAA-BBBBBBBBB-123456789123456789123456" + Expect(token.Authorization()).To(Equal(expected)) + }) + }) }) - Describe("sending the payload", func() { - var err error - BeforeEach(func() { - httpmock.Activate() - }) - AfterEach(func() { - httpmock.DeactivateAndReset() + Describe("creating the payload", func() { + Describe("the icon fields", func() { + payload := MessagePayload{} + It("should set IconURL when the configured icon looks like an URL", func() { + payload.SetIcon("https://example.com/logo.png") + Expect(payload.IconURL).To(Equal("https://example.com/logo.png")) + Expect(payload.IconEmoji).To(BeEmpty()) + }) + It("should set IconEmoji when the configured icon does not look like an URL", func() { + payload.SetIcon("tanabata_tree") + Expect(payload.IconEmoji).To(Equal("tanabata_tree")) + Expect(payload.IconURL).To(BeEmpty()) + }) + It("should clear both fields when icon is empty", func() { + payload.SetIcon("") + Expect(payload.IconEmoji).To(BeEmpty()) + Expect(payload.IconURL).To(BeEmpty()) + }) }) - It("should not report an error if the server accepts the payload", func() { - serviceURL, _ := url.Parse("slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456") - err = service.Initialize(serviceURL, logger) - Expect(err).NotTo(HaveOccurred()) - targetURL := "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" - httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(200, "")) + }) - err = service.Send("Message", nil) - Expect(err).NotTo(HaveOccurred()) + Describe("sending the payload", func() { + When("sending via webhook URL", func() { + var err error + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + It("should not report an error if the server accepts the payload", func() { + serviceURL, _ := url.Parse("slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456") + err = service.Initialize(serviceURL, logger) + Expect(err).NotTo(HaveOccurred()) + + targetURL := "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" + httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(200, "")) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + It("should not panic if an error occurs when sending the payload", func() { + serviceURL, _ := url.Parse("slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456") + err = service.Initialize(serviceURL, logger) + Expect(err).NotTo(HaveOccurred()) + + targetURL := "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" + httpmock.RegisterResponder("POST", targetURL, httpmock.NewErrorResponder(errors.New("dummy error"))) + + err = service.Send("Message", nil) + Expect(err).To(HaveOccurred()) + }) }) - It("should not panic if an error occurs when sending the payload", func() { - serviceURL, _ := url.Parse("slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456") - err = service.Initialize(serviceURL, logger) - Expect(err).NotTo(HaveOccurred()) + When("sending via bot API", func() { + var err error + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) - targetURL := "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" - httpmock.RegisterResponder("POST", targetURL, httpmock.NewErrorResponder(errors.New("dummy error"))) + It("should not report an error if the server accepts the payload", func() { + serviceURL := urlMust("slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789") + err = service.Initialize(serviceURL, logger) + Expect(err).NotTo(HaveOccurred()) - err = service.Send("Message", nil) - Expect(err).To(HaveOccurred()) + targetURL := "https://slack.com/api/chat.postMessage" + httpmock.RegisterResponder("POST", targetURL, jsonRespondMust(200, APIResponse{ + Ok: true, + })) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + It("should not panic if an error occurs when sending the payload", func() { + serviceURL := urlMust("slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789") + err = service.Initialize(serviceURL, logger) + Expect(err).NotTo(HaveOccurred()) + + targetURL := "https://slack.com/api/chat.postMessage" + httpmock.RegisterResponder("POST", targetURL, jsonRespondMust(200, APIResponse{ + Error: "someone turned off the internet", + })) + + err = service.Send("Message", nil) + Expect(err).To(HaveOccurred()) + }) }) }) }) -func expectErrorMessageGivenURL(msg ErrorMessage, slackURL *url.URL) { - err := service.Initialize(slackURL, util.TestLogger()) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal(string(msg))) +func jsonRespondMust(code int, response APIResponse) httpmock.Responder { + responder, err := httpmock.NewJsonResponder(code, response) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "invalid test response struct") + return responder +} + +func urlMust(rawURL string) *url.URL { + parsed, err := url.Parse(rawURL) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "invalid test URL string") + return parsed +} + +func tokenMust(rawToken string) *Token { + token, err := ParseToken(rawToken) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + return token +} + +func expectErrorMessageGivenURL(expected error, rawURL string) { + err := service.Initialize(urlMust(rawURL), util.TestLogger()) + ExpectWithOffset(1, err).To(HaveOccurred()) + ExpectWithOffset(1, err).To(Equal(expected)) } diff --git a/pkg/services/slack/slack_token.go b/pkg/services/slack/slack_token.go index e6f4aff2..2a506727 100644 --- a/pkg/services/slack/slack_token.go +++ b/pkg/services/slack/slack_token.go @@ -1,60 +1,129 @@ package slack import ( - "errors" + "fmt" + "github.com/containrrr/shoutrrr/pkg/types" + "net/url" "regexp" "strings" ) -// Token is a three part string split into A, B and C -type Token []string +var _ types.ConfigProp = &Token{} -// ValidateToken checks that the token is in the expected format -func ValidateToken(token Token) error { - if err := tokenPartsAreNotEmpty(token); err != nil { - return err - } else if err := tokenPartsAreValidFormat(token); err != nil { - return err - } - return nil +const ( + hookTokenIdentifier = "hook" + userTokenIdentifier = "xoxp" + botTokenIdentifier = "xoxb" +) + +// Token is a Slack API token or a Slack webhook token +type Token struct { + raw string } -func tokenPartsAreNotEmpty(token Token) error { - if token[0] == "" { - return errors.New(string(TokenAMissing)) - } else if token[1] == "" { - return errors.New(string(TokenBMissing)) - } else if token[2] == "" { - return errors.New(string(TokenCMissing)) +// SetFromProp updates it's state according to the passed string +// (implementation of the types.ConfigProp interface) +func (token *Token) SetFromProp(propValue string) error { + if len(propValue) < 3 { + return ErrorInvalidToken + } + + match := tokenPattern.FindStringSubmatch(propValue) + if match == nil || len(match) != tokenMatchCount { + return ErrorInvalidToken + } + + typeIdentifier := match[tokenMatchType] + if typeIdentifier == "" { + typeIdentifier = hookTokenIdentifier + } + + token.raw = fmt.Sprintf("%s:%s-%s-%s", + typeIdentifier, match[tokenMatchPart1], match[tokenMatchPart2], match[tokenMatchPart3]) + + if match[tokenMatchSep1] != match[tokenMatchSep2] { + return ErrorMismatchedTokenSeparators } + return nil } -func tokenPartsAreValidFormat(token Token) error { - if !matchesPattern("[A-Z0-9]{9}", token[0]) { - return errors.New(string(TokenAMalformed)) - } else if !matchesPattern("[A-Z0-9]{9}", token[1]) { - return errors.New(string(TokenBMalformed)) - } else if !matchesPattern("[A-Za-z0-9]{24}", token[2]) { - return errors.New(string(TokenCMalformed)) +// GetPropValue returns a deserializable string representation of the token +// (implementation of the types.ConfigProp interface) +func (token *Token) GetPropValue() (string, error) { + if token == nil { + return "", nil } - return nil + + return token.raw, nil } -func matchesPattern(pattern string, part string) bool { - matched, err := regexp.Match(pattern, []byte(part)) - if matched != true || err != nil { - return false +// TypeIdentifier returns the type identifier of the token +func (token Token) TypeIdentifier() string { + return token.raw[:4] +} + +// ParseToken parses and normalizes a token string +func ParseToken(str string) (*Token, error) { + token := &Token{} + if err := token.SetFromProp(str); err != nil { + return nil, err } - return true + return token, nil +} + +const ( + tokenMatchFull = iota + tokenMatchType + tokenMatchPart1 + tokenMatchSep1 + tokenMatchPart2 + tokenMatchSep2 + tokenMatchPart3 + tokenMatchCount +) + +var tokenPattern = regexp.MustCompile(`(?:(?Pxox.|hook)[-:]|:?)(?P[A-Z0-9]{9,})(?P[-/,])(?P[A-Z0-9]{9,})(?P[-/,])(?P[A-Za-z0-9]{24,})`) + +// String returns the token in normalized format with dashes (-) as separator +func (token *Token) String() string { + return token.raw } -func (t Token) String() string { - return strings.Join(t, "-") +// UserInfo returns a url.Userinfo struct populated from the token +func (token *Token) UserInfo() *url.Userinfo { + return url.UserPassword(token.raw[:4], token.raw[5:]) +} + +// IsAPIToken returns whether the identifier is set to anything else but the webhook identifier (`hook`) +func (token *Token) IsAPIToken() bool { + return token.TypeIdentifier() != hookTokenIdentifier +} + +const webhookBase = "https://hooks.slack.com/services/" + +// WebhookURL returns the corresponding Webhook URL for the Token +func (token Token) WebhookURL() string { + sb := strings.Builder{} + sb.WriteString(webhookBase) + sb.Grow(len(token.raw) - 5) + for i := 5; i < len(token.raw); i++ { + c := token.raw[i] + if c == '-' { + c = '/' + } + sb.WriteByte(c) + } + return sb.String() } -// ParseToken creates a Token from a sting representation -func ParseToken(s string) Token { - token := strings.Split(s, "-") - return token +// Authorization returns the corresponding `Authorization` HTTP header value for the Token +func (token *Token) Authorization() string { + sb := strings.Builder{} + sb.WriteString("Bearer ") + sb.Grow(len(token.raw)) + sb.WriteString(token.raw[:4]) + sb.WriteRune('-') + sb.WriteString(token.raw[5:]) + return sb.String() } diff --git a/pkg/util/generator/generator_common.go b/pkg/util/generator/generator_common.go index 9bc84434..38035a50 100644 --- a/pkg/util/generator/generator_common.go +++ b/pkg/util/generator/generator_common.go @@ -124,6 +124,7 @@ func (ud *UserDialog) QueryString(prompt string, validator func(string) error, k if err := validator(answer); err != nil { ud.Writeln("%v", err) + ud.Writeln("") continue } return answer diff --git a/pkg/util/generator/generator_test.go b/pkg/util/generator/generator_test.go index 645fb665..1388ec7a 100644 --- a/pkg/util/generator/generator_test.go +++ b/pkg/util/generator/generator_test.go @@ -3,6 +3,7 @@ package generator_test import ( "fmt" "github.com/containrrr/shoutrrr/pkg/util/generator" + "github.com/mattn/go-colorable" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/onsi/gomega/gbytes" @@ -37,51 +38,51 @@ func dumpBuffers() { } var _ = Describe("GeneratorCommon", func() { - Describe("attach to the data stream", func() { - - BeforeEach(func() { - userOut = gbytes.NewBuffer() - userIn = gbytes.NewBuffer() - client = generator.NewUserDialog(userOut, userIn, map[string]string{"propKey": "propVal"}) - }) + BeforeEach(func() { + userOut = gbytes.NewBuffer() + userIn = gbytes.NewBuffer() + userInMono := colorable.NewNonColorable(userIn) + client = generator.NewUserDialog(userOut, userInMono, map[string]string{"propKey": "propVal"}) + }) - It("reprompt upon invalid answers", func() { - defer dumpBuffers() - answer := make(chan string) - go func() { - answer <- client.QueryString("name:", generator.Required, "") - }() + It("reprompt upon invalid answers", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "") + }() - mockTyped("") - mockTyped("Normal Human Name") + mockTyped("") + mockTyped("Normal Human Name") - Eventually(userIn).Should(gbytes.Say(`name: `)) + Eventually(userIn).Should(gbytes.Say(`name: `)) - Eventually(userIn).Should(gbytes.Say(`field is required`)) - Eventually(userIn).Should(gbytes.Say(`name: `)) - Eventually(answer).Should(Receive(Equal("Normal Human Name"))) - }) + Eventually(userIn).Should(gbytes.Say(`field is required`)) + Eventually(userIn).Should(gbytes.Say(`name: `)) + Eventually(answer).Should(Receive(Equal("Normal Human Name"))) + }) - It("should accept any input when validator is nil", func() { - defer dumpBuffers() - answer := make(chan string) - go func() { - answer <- client.QueryString("name:", nil, "") - }() - mockTyped("") - Eventually(answer).Should(Receive(BeEmpty())) - }) + It("should accept any input when validator is nil", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", nil, "") + }() + mockTyped("") + Eventually(answer).Should(Receive(BeEmpty())) + }) - It("should use predefined prop value if key is present", func() { - defer dumpBuffers() - answer := make(chan string) - go func() { - answer <- client.QueryString("name:", generator.Required, "propKey") - }() - Eventually(answer).Should(Receive(Equal("propVal"))) - }) + It("should use predefined prop value if key is present", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "propKey") + }() + Eventually(answer).Should(Receive(Equal("propVal"))) + }) - It("Query", func() { + Describe("Query", func() { + It("should prompt until a valid answer is provided", func() { defer dumpBuffers() answer := make(chan []string) query := "pick foo or bar:" @@ -97,8 +98,10 @@ var _ = Describe("GeneratorCommon", func() { Eventually(userIn).Should(gbytes.Say(query)) Eventually(answer).Should(Receive(ContainElement("foo"))) }) + }) - It("QueryAll", func() { + Describe("QueryAll", func() { + It("should prompt until a valid answer is provided", func() { defer dumpBuffers() answer := make(chan [][]string) query := "pick foo or bar:" @@ -114,8 +117,10 @@ var _ = Describe("GeneratorCommon", func() { Expect(matches).To(ContainElement([]string{"foobar", "bar"})) Expect(matches).To(ContainElement([]string{"foobaz", "baz"})) }) + }) - It("QueryStringPattern", func() { + Describe("QueryStringPattern", func() { + It("should prompt until a valid answer is provided", func() { defer dumpBuffers() answer := make(chan string) query := "type of bar:" @@ -131,8 +136,10 @@ var _ = Describe("GeneratorCommon", func() { Eventually(userIn).Should(gbytes.Say(query)) Eventually(answer).Should(Receive(Equal("foobar"))) }) + }) - It("QueryInt", func() { + Describe("QueryInt", func() { + It("should prompt until a valid answer is provided", func() { defer dumpBuffers() answer := make(chan int64) query := "number:" @@ -148,8 +155,10 @@ var _ = Describe("GeneratorCommon", func() { Eventually(userIn).Should(gbytes.Say(query)) Eventually(answer).Should(Receive(Equal(int64(32)))) }) + }) - It("QueryBool", func() { + Describe("QueryBool", func() { + It("should prompt until a valid answer is provided", func() { defer dumpBuffers() answer := make(chan bool) query := "cool?" From f16b81d880ed08742e23f9bd4f6e1f6b7c07ad15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 15:19:58 +0200 Subject: [PATCH 13/22] fix(teams): use correct path in webhook URL (#188) * add static version meta info * fix(teams): use correct path in webhook URL --- cli/main.go | 6 ++-- internal/meta/version.go | 7 +++++ pkg/services/teams/teams.go | 12 +++++--- pkg/services/teams/teams_config.go | 29 +++++++++++++----- pkg/services/teams/teams_test.go | 48 ++++++++++++++++++++---------- pkg/util/docs.go | 17 +++++++++++ pkg/util/partition_message_test.go | 4 --- pkg/util/util_test.go | 13 ++++++++ shoutrrr.go | 6 ++++ 9 files changed, 108 insertions(+), 34 deletions(-) create mode 100644 internal/meta/version.go create mode 100644 pkg/util/docs.go diff --git a/cli/main.go b/cli/main.go index 22c28c09..cb544b1b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -6,14 +6,16 @@ import ( "github.com/containrrr/shoutrrr/cli/cmd/generate" "github.com/containrrr/shoutrrr/cli/cmd/send" "github.com/containrrr/shoutrrr/cli/cmd/verify" + "github.com/containrrr/shoutrrr/internal/meta" "github.com/spf13/cobra" "github.com/spf13/viper" "os" ) var cmd = &cobra.Command{ - Use: "shoutrrr", - Short: "Notification library for gophers and their furry friends", + Use: "shoutrrr", + Version: meta.Version, + Short: "Shoutrrr CLI", } func init() { diff --git a/internal/meta/version.go b/internal/meta/version.go new file mode 100644 index 00000000..b60e41aa --- /dev/null +++ b/internal/meta/version.go @@ -0,0 +1,7 @@ +package meta + +// Version of Shoutrrr +const Version = `0.5-dev` + +// DocsVersion is prepended to documentation URLs and usually equals MAJOR.MINOR of Version +const DocsVersion = `dev` diff --git a/pkg/services/teams/teams.go b/pkg/services/teams/teams.go index 151b1aad..0c59ba67 100644 --- a/pkg/services/teams/teams.go +++ b/pkg/services/teams/teams.go @@ -4,13 +4,14 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/containrrr/shoutrrr/pkg/format" "net/http" "net/url" "strings" + "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" ) // Service providing teams as a notification service @@ -35,7 +36,7 @@ func (service *Service) Send(message string, params *types.Params) error { func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { service.Logger.SetLogger(logger) service.config = &Config{ - Host: DefaultHost, + Host: LegacyHost, } service.pkr = format.NewPropKeyResolver(service.config) @@ -93,9 +94,12 @@ func (service *Service) doSend(config *Config, message string) error { host := config.Host if host == "" { - host = DefaultHost + host = LegacyHost + // Emit a warning to the log for now. + // TODO(v0.6): Remove legacy support as it should be fully deprecated now + service.Logf(`Warning: No host specified, update your Teams URL: %s`, util.DocsURL(`services/teams`)) } - postURL := buildWebhookURL(host, config.webhookParts()) + postURL := buildWebhookURL(host, config.Group, config.Tenant, config.AltID, config.GroupOwner) res, err := http.Post(postURL, "application/json", bytes.NewBuffer(payload)) if err == nil && res.StatusCode != http.StatusOK { diff --git a/pkg/services/teams/teams_config.go b/pkg/services/teams/teams_config.go index c0e5e2c7..c47a420b 100644 --- a/pkg/services/teams/teams_config.go +++ b/pkg/services/teams/teams_config.go @@ -114,14 +114,21 @@ func (config *Config) setFromWebhookParts(parts [4]string) { config.GroupOwner = parts[3] } -func buildWebhookURL(host string, parts [4]string) string { +func buildWebhookURL(host, group, tenant, altID, groupOwner string) string { + // config.Group, config.Tenant, config.AltID, config.GroupOwner + path := Path + if host == LegacyHost { + path = LegacyPath + } return fmt.Sprintf( - "https://%s/webhook/%s@%s/IncomingWebhook/%s/%s", + "https://%s/%s/%s@%s/%s/%s/%s", host, - parts[0], - parts[1], - parts[2], - parts[3]) + path, + group, + tenant, + ProviderName, + altID, + groupOwner) } func parseAndVerifyWebhookURL(webhookURL string) (parts [4]string, err error) { @@ -142,6 +149,12 @@ func parseAndVerifyWebhookURL(webhookURL string) (parts [4]string, err 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" + // LegacyHost is the default host for legacy webhook requests + LegacyHost = "outlook.office.com" + // LegacyPath is the initial path of the webhook URL for legacy webhook requests + LegacyPath = "webhook" + // Path is the initial path of the webhook URL for domain-scoped webhook requests + Path = "webhookb2" + // ProviderName is the name of the Teams integration provider + ProviderName = "IncomingWebhook" ) diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 48241e51..a3e3d22b 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -12,9 +12,11 @@ import ( ) const ( - testWebhookURL = "https://outlook.office.com/webhook/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" - customURL = "teams+https://publicservice.info/webhook/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" - testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" + legacyWebhookURL = "https://outlook.office.com/webhook/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" + scopedWebhookURL = "https://test.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" + scopedDomainHost = "test.webhook.office.com" + testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" + scopedURLBase = testURLBase + `?host=` + scopedDomainHost ) var logger = log.New(GinkgoWriter, "Test", log.LstdFlags) @@ -24,9 +26,9 @@ func TestTeams(t *testing.T) { RunSpecs(t, "Shoutrrr Teams Suite") } -var _ = Describe("the teams plugin", func() { +var _ = Describe("the teams service", func() { When("creating the webhook URL", func() { - It("should match the expected output", func() { + It("should match the expected output for legacy URLs", func() { config := Config{} config.setFromWebhookParts([4]string{ "11111111-4444-4444-8444-cccccccccccc", @@ -34,8 +36,23 @@ var _ = Describe("the teams plugin", func() { "33333333012222222222333333333344", "44444444-4444-4444-8444-cccccccccccc", }) - apiURL := buildWebhookURL(DefaultHost, config.webhookParts()) - Expect(apiURL).To(Equal(testWebhookURL)) + apiURL := buildWebhookURL(LegacyHost, config.Group, config.Tenant, config.AltID, config.GroupOwner) + Expect(apiURL).To(Equal(legacyWebhookURL)) + + parts, err := parseAndVerifyWebhookURL(apiURL) + Expect(err).ToNot(HaveOccurred()) + Expect(parts).To(Equal(config.webhookParts())) + }) + It("should match the expected output for custom URLs", func() { + config := Config{} + config.setFromWebhookParts([4]string{ + "11111111-4444-4444-8444-cccccccccccc", + "22222222-4444-4444-8444-cccccccccccc", + "33333333012222222222333333333344", + "44444444-4444-4444-8444-cccccccccccc", + }) + apiURL := buildWebhookURL(scopedDomainHost, config.Group, config.Tenant, config.AltID, config.GroupOwner) + Expect(apiURL).To(Equal(scopedWebhookURL)) parts, err := parseAndVerifyWebhookURL(apiURL) Expect(err).ToNot(HaveOccurred()) @@ -46,18 +63,17 @@ var _ = Describe("the teams plugin", func() { Describe("creating a config", func() { When("parsing the configuration URL", func() { It("should be identical after de-/serialization", func() { - testURL := testURLBase + "?color=aabbcc&host=notdefault.outlook.office.com&title=Test+title" + testURL := testURLBase + "?color=aabbcc&host=test.outlook.office.com&title=Test+title" url, err := url.Parse(testURL) Expect(err).NotTo(HaveOccurred(), "parsing") - config := &Config{Host: DefaultHost} + config := &Config{Host: LegacyHost} err = config.SetURL(url) Expect(err).NotTo(HaveOccurred(), "verifying") outputURL := config.GetURL() Expect(outputURL.String()).To(Equal(testURL)) - }) }) }) @@ -78,7 +94,7 @@ var _ = Describe("the teams plugin", func() { When("a valid custom URL is provided", func() { It("should set the host field from the custom URL", func() { service := Service{} - testURL := customURL + testURL := `teams+` + scopedWebhookURL customURL, err := url.Parse(testURL) Expect(err).NotTo(HaveOccurred(), "parsing") @@ -86,11 +102,11 @@ var _ = Describe("the teams plugin", func() { serviceURL, err := service.GetConfigURLFromCustom(customURL) Expect(err).NotTo(HaveOccurred(), "converting") - Expect(serviceURL.String()).To(Equal(testURLBase + "?host=publicservice.info")) + Expect(serviceURL.String()).To(Equal(scopedURLBase)) }) It("should preserve the query params in the generated service URL", func() { service := Service{} - testURL := "teams+" + testWebhookURL + "?color=f008c1&title=TheTitle" + testURL := "teams+" + legacyWebhookURL + "?color=f008c1&title=TheTitle" customURL, err := url.Parse(testURL) Expect(err).NotTo(HaveOccurred(), "parsing") @@ -113,11 +129,11 @@ var _ = Describe("the teams plugin", func() { httpmock.DeactivateAndReset() }) It("should not report an error if the server accepts the payload", func() { - serviceURL, _ := url.Parse(testURLBase) + serviceURL, _ := url.Parse(scopedURLBase) err = service.Initialize(serviceURL, logger) Expect(err).NotTo(HaveOccurred()) - httpmock.RegisterResponder("POST", testWebhookURL, httpmock.NewStringResponder(200, "")) + httpmock.RegisterResponder("POST", scopedWebhookURL, httpmock.NewStringResponder(200, "")) err = service.Send("Message", nil) Expect(err).NotTo(HaveOccurred()) @@ -127,7 +143,7 @@ var _ = Describe("the teams plugin", func() { err = service.Initialize(serviceURL, logger) Expect(err).NotTo(HaveOccurred()) - httpmock.RegisterResponder("POST", testWebhookURL, httpmock.NewErrorResponder(errors.New("dummy error"))) + httpmock.RegisterResponder("POST", legacyWebhookURL, httpmock.NewErrorResponder(errors.New("dummy error"))) err = service.Send("Message", nil) Expect(err).To(HaveOccurred()) diff --git a/pkg/util/docs.go b/pkg/util/docs.go new file mode 100644 index 00000000..2d137e1a --- /dev/null +++ b/pkg/util/docs.go @@ -0,0 +1,17 @@ +package util + +import ( + "fmt" + + "github.com/containrrr/shoutrrr/internal/meta" +) + +// DocsURL returns a full documentation URL for the current version of Shoutrrr with the path appended. +// If the path contains a leading slash, it is stripped. +func DocsURL(path string) string { + // strip leading slash if present + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + return fmt.Sprintf("https://containrrr.dev/shoutrrr/%s/%s", meta.DocsVersion, path) +} diff --git a/pkg/util/partition_message_test.go b/pkg/util/partition_message_test.go index fb0ace3a..e3201616 100644 --- a/pkg/util/partition_message_test.go +++ b/pkg/util/partition_message_test.go @@ -118,14 +118,10 @@ func testPartitionMessage(hundreds int, limits types.MessageLimit, distance int) items, omitted = PartitionMessage(builder.String(), limits, distance) - println(hundreds, len(items), omitted) - contentSize := Min(hundreds*100, limits.TotalChunkSize) expectedChunkCount := CeilDiv(contentSize, limits.ChunkSize-1) expectedOmitted := Max(0, (hundreds*100)-contentSize) - println(contentSize, expectedChunkCount, expectedOmitted) - Expect(omitted).To(Equal(expectedOmitted)) Expect(len(items)).To(Equal(expectedChunkCount)) diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index f789a913..34f58b4c 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -1,12 +1,14 @@ package util_test import ( + "fmt" "reflect" "testing" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "github.com/containrrr/shoutrrr/internal/meta" . "github.com/containrrr/shoutrrr/pkg/util" ) @@ -91,4 +93,15 @@ var _ = Describe("the util package", func() { Expect(IsNumeric(reflect.TypeOf("3").Kind())).To(BeFalse()) }) }) + + When("calling function DocsURL", func() { + It("should return the expected URL", func() { + expectedBase := fmt.Sprintf(`https://containrrr.dev/shoutrrr/%s/`, meta.DocsVersion) + Expect(DocsURL(``)).To(Equal(expectedBase)) + Expect(DocsURL(`services/logger`)).To(Equal(expectedBase + `services/logger`)) + }) + It("should strip the leading slash from the path", func() { + Expect(DocsURL(`/foo`)).To(Equal(DocsURL(`foo`))) + }) + }) }) diff --git a/shoutrrr.go b/shoutrrr.go index 8e005409..30dd897b 100644 --- a/shoutrrr.go +++ b/shoutrrr.go @@ -1,6 +1,7 @@ package shoutrrr import ( + "github.com/containrrr/shoutrrr/internal/meta" "github.com/containrrr/shoutrrr/pkg/router" "github.com/containrrr/shoutrrr/pkg/types" ) @@ -32,3 +33,8 @@ func CreateSender(rawURLs ...string) (*router.ServiceRouter, error) { func NewSender(logger types.StdLogger, serviceURLs ...string) (*router.ServiceRouter, error) { return router.New(logger, serviceURLs...) } + +// Version returns the shoutrrr version +func Version() string { + return meta.Version +} From 265789c2955f9d5fed6419d4f3b2fa07660cb1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 15:47:26 +0200 Subject: [PATCH 14/22] fix(router): check for nil logger (#189) --- pkg/router/router.go | 11 +++++++++-- pkg/router/router_suite_test.go | 13 ++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pkg/router/router.go b/pkg/router/router.go index 0b9449cc..d9931bc9 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -186,7 +186,7 @@ func (router *ServiceRouter) initService(rawURL string) (t.Service, error) { } if configURL.Scheme != scheme { - router.logger.Println("Got custom URL:", configURL.String()) + router.log("Got custom URL:", configURL.String()) customURLService, ok := service.(t.CustomURLService) if !ok { return nil, fmt.Errorf("custom URLs are not supported by '%s' service", scheme) @@ -195,7 +195,7 @@ func (router *ServiceRouter) initService(rawURL string) (t.Service, error) { if err != nil { return nil, err } - router.logger.Println("Converted service URL:", configURL.String()) + router.log("Converted service URL:", configURL.String()) } err = service.Initialize(configURL, router.logger) @@ -238,3 +238,10 @@ func (router *ServiceRouter) Locate(rawURL string) (t.Service, error) { service, err := router.initService(rawURL) return service, err } + +func (router *ServiceRouter) log(v ...interface{}) { + if router.logger == nil { + return + } + router.logger.Println(v...) +} diff --git a/pkg/router/router_suite_test.go b/pkg/router/router_suite_test.go index 9b2d8330..3f347340 100644 --- a/pkg/router/router_suite_test.go +++ b/pkg/router/router_suite_test.go @@ -17,6 +17,10 @@ func TestRouter(t *testing.T) { var sr ServiceRouter +const ( + mockCustomURL = "teams+https://publicservice.info/webhook/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc" +) + var _ = Describe("the router suite", func() { BeforeEach(func() { sr = ServiceRouter{ @@ -84,7 +88,7 @@ var _ = Describe("the router suite", func() { 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") + service, err := sr.initService(mockCustomURL) Expect(err).NotTo(HaveOccurred()) Expect(service).NotTo(BeNil()) }) @@ -113,6 +117,13 @@ var _ = Describe("the router suite", func() { }) }) }) + When("router has not been provided a logger", func() { + It("should not crash when trying to log", func() { + router := ServiceRouter{} + _, err := router.initService(mockCustomURL) + Expect(err).NotTo(HaveOccurred()) + }) + }) }) func ExampleNew() { From db00936b6a0874e66c50a4a96be6270dba2c9ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 19:52:45 +0200 Subject: [PATCH 15/22] test: add basic renderer tests (#191) --- cli/cmd/docs/docs.go | 5 ++- pkg/format/node.go | 11 +++-- pkg/format/render_console_test.go | 56 +++++++++++++++++++++++++ pkg/format/render_markdown.go | 43 +++++++++++-------- pkg/format/render_markdown_test.go | 67 ++++++++++++++++++++++++++++++ pkg/format/renderer.go | 11 +++++ pkg/types/service_config.go | 7 +++- 7 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 pkg/format/render_console_test.go create mode 100644 pkg/format/render_markdown_test.go create mode 100644 pkg/format/renderer.go diff --git a/cli/cmd/docs/docs.go b/cli/cmd/docs/docs.go index 60135e34..0a19b0ca 100644 --- a/cli/cmd/docs/docs.go +++ b/cli/cmd/docs/docs.go @@ -50,7 +50,10 @@ func printDocs(format string, services []string) cli.Result { case "console": renderer = f.ConsoleTreeRenderer{WithValues: false} case "markdown": - renderer = f.MarkdownTreeRenderer{HeaderPrefix: "### "} + renderer = f.MarkdownTreeRenderer{ + HeaderPrefix: "### ", + PropsDescription: "Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n", + } default: return cli.InvalidUsage("invalid format") } diff --git a/pkg/format/node.go b/pkg/format/node.go index 033b0933..de0bcd75 100644 --- a/pkg/format/node.go +++ b/pkg/format/node.go @@ -176,8 +176,8 @@ func getNode(fieldVal r.Value, fieldInfo *FieldInfo) Node { } } -func getRootNode(config types.ServiceConfig) *ContainerNode { - structValue := r.ValueOf(config) +func getRootNode(v interface{}) *ContainerNode { + structValue := r.ValueOf(v) if structValue.Kind() == r.Ptr { structValue = structValue.Elem() } @@ -186,7 +186,12 @@ func getRootNode(config types.ServiceConfig) *ContainerNode { Type: structValue.Type(), } - infoFields := getStructFieldInfo(fieldInfo.Type, config.Enums()) + enums := map[string]types.EnumFormatter{} + if enummer, isEnummer := v.(types.Enummer); isEnummer { + enums = enummer.Enums() + } + + infoFields := getStructFieldInfo(fieldInfo.Type, enums) numFields := len(infoFields) nodeItems := make([]Node, numFields) diff --git a/pkg/format/render_console_test.go b/pkg/format/render_console_test.go new file mode 100644 index 00000000..5587bd9f --- /dev/null +++ b/pkg/format/render_console_test.go @@ -0,0 +1,56 @@ +package format + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + gformat "github.com/onsi/gomega/format" +) + +var _ = Describe("RenderConsole", func() { + gformat.CharactersAroundMismatchToInclude = 30 + renderer := ConsoleTreeRenderer{WithValues: false} + + It("should render the expected output based on config reflection/tags", func() { + actual := testRenderTee(renderer, &struct { + Name string `default:"notempty"` + Host string `url:"host"` + }{}) + + expected := ` +Host string +Name string +`[1:] + println() + println(actual) + + Expect(actual).To(Equal(expected)) + }) + + It("should render url paths in sorted order", func() { + actual := testRenderTee(renderer, &struct { + Host string `url:"host"` + Path1 string `url:"path1"` + Path3 string `url:"path3"` + Path2 string `url:"path2"` + }{}) + + expected := ` +Host string +Path1 string +Path2 string +Path3 string +`[1:] + + println() + println(actual) + + Expect(actual).To(Equal(expected)) + }) +}) + +/* + +* __TestEnum__ + Default: `+"`None`"+` + Possible values: `+"`None`, `Foo`, `Bar`"+` +*/ diff --git a/pkg/format/render_markdown.go b/pkg/format/render_markdown.go index 623033ab..07f420c0 100644 --- a/pkg/format/render_markdown.go +++ b/pkg/format/render_markdown.go @@ -10,7 +10,8 @@ import ( // MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string type MarkdownTreeRenderer struct { - HeaderPrefix string + HeaderPrefix string + PropsDescription string } // RenderTree renders a ContainerNode tree into a markdown documentation string @@ -20,7 +21,6 @@ func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) str queryFields := make([]*FieldInfo, 0, len(root.Items)) urlFields := make([]*FieldInfo, URLPath+1) - fieldsPrinted := make(map[string]bool) for _, node := range root.Items { field := node.Field() @@ -38,6 +38,27 @@ func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) str } } + r.writeURLFields(&sb, urlFields, scheme) + + sort.SliceStable(queryFields, func(i, j int) bool { + return queryFields[i].Required && !queryFields[j].Required + }) + + r.writeHeader(&sb, "Query/Param Props") + sb.WriteString(r.PropsDescription) + sb.WriteRune('\n') + for _, field := range queryFields { + r.writeFieldPrimary(&sb, field) + r.writeFieldExtras(&sb, field) + sb.WriteRune('\n') + } + + return sb.String() +} + +func (r MarkdownTreeRenderer) writeURLFields(sb *strings.Builder, urlFields []*FieldInfo, scheme string) { + fieldsPrinted := make(map[string]bool) + sort.SliceStable(urlFields, func(i, j int) bool { if urlFields[i] == nil || urlFields[j] == nil { return false @@ -56,12 +77,12 @@ func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) str return urlPartA < urlPartB }) - r.writeHeader(&sb, "URL Fields") + r.writeHeader(sb, "URL Fields") for _, field := range urlFields { if field == nil || fieldsPrinted[field.Name] { continue } - r.writeFieldPrimary(&sb, field) + r.writeFieldPrimary(sb, field) sb.WriteString(" URL part: ") @@ -109,20 +130,6 @@ func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) str fieldsPrinted[field.Name] = true } - - sort.SliceStable(queryFields, func(i, j int) bool { - return queryFields[i].Required && !queryFields[j].Required - }) - - r.writeHeader(&sb, "Query/Param Props") - sb.WriteString("Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n\n") - for _, field := range queryFields { - r.writeFieldPrimary(&sb, field) - r.writeFieldExtras(&sb, field) - sb.WriteRune('\n') - } - - return sb.String() } func (MarkdownTreeRenderer) writeFieldExtras(sb *strings.Builder, field *FieldInfo) { diff --git a/pkg/format/render_markdown_test.go b/pkg/format/render_markdown_test.go new file mode 100644 index 00000000..adc8ac33 --- /dev/null +++ b/pkg/format/render_markdown_test.go @@ -0,0 +1,67 @@ +package format + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + gformat "github.com/onsi/gomega/format" +) + +var _ = Describe("RenderMarkdown", func() { + gformat.CharactersAroundMismatchToInclude = 30 + + It("should render the expected output based on config reflection/tags", func() { + actual := testRenderTee(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Name string `default:"notempty"` + Host string `url:"host"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + + +* __Name__ + Default: `[1:] + "`notempty`" + ` + +` + + Expect(actual).To(Equal(expected)) + }) + + It("should render url paths in sorted order", func() { + actual := testRenderTee(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Host string `url:"host"` + Path1 string `url:"path1"` + Path3 string `url:"path3"` + Path2 string `url:"path2"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path1__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path2__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path3__ (**Required**) + URL part: mock://host/path1/path2/path3 +### Query/Param Props + + +`[1:] // Remove initial newline + + Expect(actual).To(Equal(expected)) + }) +}) + +/* + +* __TestEnum__ + Default: `+"`None`"+` + Possible values: `+"`None`, `Foo`, `Bar`"+` +*/ diff --git a/pkg/format/renderer.go b/pkg/format/renderer.go new file mode 100644 index 00000000..369c7ff9 --- /dev/null +++ b/pkg/format/renderer.go @@ -0,0 +1,11 @@ +package format + +// Renderer renders a supplied node tree to a string +type Renderer interface { + // RenderTree renders a ContainerNode tree into a ansi-colored console string + RenderTree(root *ContainerNode, scheme string) string +} + +func testRenderTee(r Renderer, v interface{}) string { + return r.RenderTree(getRootNode(v), "mock") +} diff --git a/pkg/types/service_config.go b/pkg/types/service_config.go index eb7273d7..80c6d68b 100644 --- a/pkg/types/service_config.go +++ b/pkg/types/service_config.go @@ -2,11 +2,16 @@ package types import "net/url" +// Enummer contains fields that have associated EnumFormatter instances +type Enummer interface { + Enums() map[string]EnumFormatter +} + // ServiceConfig is the common interface for all types of service configurations type ServiceConfig interface { + Enummer GetURL() *url.URL SetURL(*url.URL) error - Enums() map[string]EnumFormatter } // ConfigQueryResolver is the interface used to get/set and list service config query fields From 603fafc681dfcfa0c17d1cf93f293f4ec1332f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 20:10:57 +0200 Subject: [PATCH 16/22] test(telegram): add generator test (#190) --- pkg/services/telegram/telegram_generator.go | 20 +++- .../telegram/telegram_generator_test.go | 112 ++++++++++++++++++ pkg/services/telegram/telegram_json.go | 8 +- pkg/services/telegram/telegram_test.go | 2 - 4 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 pkg/services/telegram/telegram_generator_test.go diff --git a/pkg/services/telegram/telegram_generator.go b/pkg/services/telegram/telegram_generator.go index 2b3e945f..db6f2411 100644 --- a/pkg/services/telegram/telegram_generator.go +++ b/pkg/services/telegram/telegram_generator.go @@ -4,12 +4,13 @@ import ( f "github.com/containrrr/shoutrrr/pkg/format" "github.com/containrrr/shoutrrr/pkg/types" "github.com/containrrr/shoutrrr/pkg/util/generator" - "os/signal" - "syscall" "fmt" + "io" "os" + "os/signal" "strconv" + "syscall" ) // Generator is the telegram-specific URL generator @@ -23,13 +24,20 @@ type Generator struct { owner *User statusMessage int64 botName string + Reader io.Reader + Writer io.Writer } // Generate a telegram Shoutrrr configuration from a user dialog func (g *Generator) Generate(_ types.Service, props map[string]string, _ []string) (types.ServiceConfig, error) { var config Config - - g.ud = generator.NewUserDialog(os.Stdin, os.Stdout, props) + if g.Reader == nil { + g.Reader = os.Stdin + } + if g.Writer == nil { + g.Writer = os.Stdout + } + g.ud = generator.NewUserDialog(g.Reader, g.Writer, props) ud := g.ud ud.Writeln("To start we need your bot token. If you haven't created a bot yet, you can use this link:") @@ -50,7 +58,7 @@ func (g *Generator) Generate(_ types.Service, props map[string]string, _ []strin g.botName = botInfo.Username ud.Writeln("") ud.Writeln("Okay! %v will listen for any messages in PMs and group chats it is invited to.", - f.ColorizeString("@", g.botName, ":")) + f.ColorizeString("@", g.botName)) g.done = false lastUpdate := 0 @@ -132,7 +140,7 @@ func (g *Generator) Generate(_ types.Service, props map[string]string, _ []strin return &config, nil } -func (g *Generator) addChat(chat *chat) (result string) { +func (g *Generator) addChat(chat *Chat) (result string) { id := strconv.FormatInt(chat.ID, 10) name := chat.Name() diff --git a/pkg/services/telegram/telegram_generator_test.go b/pkg/services/telegram/telegram_generator_test.go new file mode 100644 index 00000000..e88d8e16 --- /dev/null +++ b/pkg/services/telegram/telegram_generator_test.go @@ -0,0 +1,112 @@ +package telegram_test + +import ( + "fmt" + "github.com/jarcoal/httpmock" + "github.com/mattn/go-colorable" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + "io" + "strings" + + "github.com/containrrr/shoutrrr/pkg/services/telegram" +) + +const ( + mockToken = `0:MockToken` + mockAPIBase = "https://api.telegram.org/bot" + mockToken + "/" +) + +var ( + userOut *gbytes.Buffer + userIn *gbytes.Buffer + userInMono io.Writer +) + +func mockTyped(a ...interface{}) { + _, _ = fmt.Fprint(userOut, a...) + _, _ = fmt.Fprint(userOut, "\n") +} + +func dumpBuffers() { + for _, line := range strings.Split(string(userIn.Contents()), "\n") { + println(">", line) + } + for _, line := range strings.Split(string(userOut.Contents()), "\n") { + println("<", line) + } +} + +func mockAPI(endpoint string) string { + return mockAPIBase + endpoint +} + +var _ = Describe("TelegramGenerator", func() { + + BeforeEach(func() { + userOut = gbytes.NewBuffer() + userIn = gbytes.NewBuffer() + userInMono = colorable.NewNonColorable(userIn) + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should return the ", func() { + gen := telegram.Generator{ + Reader: userOut, + Writer: userInMono, + } + + resultChannel := make(chan string, 1) + + httpmock.RegisterResponder("GET", mockAPI(`getMe`), httpmock.NewJsonResponderOrPanic(200, &struct { + OK bool + Result *telegram.User + }{ + true, &telegram.User{ + ID: 1, + IsBot: true, + Username: "mockbot", + }, + })) + + httpmock.RegisterResponder("POST", mockAPI(`getUpdates`), httpmock.NewJsonResponderOrPanic(200, &struct { + OK bool + Result []telegram.Update + }{ + true, + []telegram.Update{ + { + Message: &telegram.Message{ + Text: "hi!", + From: &telegram.User{Username: `mockUser`}, + Chat: &telegram.Chat{Type: `private`, ID: 667, Username: `mockUser`}, + }, + }, + }, + })) + + go func() { + defer GinkgoRecover() + conf, err := gen.Generate(nil, nil, nil) + + Expect(conf).ToNot(BeNil()) + Expect(err).NotTo(HaveOccurred()) + resultChannel <- conf.GetURL().String() + }() + + defer dumpBuffers() + + mockTyped(mockToken) + mockTyped(`no`) + + Eventually(userIn).Should(gbytes.Say(`Got 1 chat ID\(s\) so far\. Want to add some more\?`)) + Eventually(userIn).Should(gbytes.Say(`Selected chats:`)) + Eventually(userIn).Should(gbytes.Say(`667 \(private\) @mockUser`)) + + Eventually(resultChannel).Should(Receive(Equal(`telegram://0:MockToken@telegram?chats=667&preview=No`))) + }) + +}) diff --git a/pkg/services/telegram/telegram_json.go b/pkg/services/telegram/telegram_json.go index a44930ed..144b3e34 100644 --- a/pkg/services/telegram/telegram_json.go +++ b/pkg/services/telegram/telegram_json.go @@ -18,7 +18,7 @@ type Message struct { MessageID int64 `json:"message_id"` Text string `json:"text"` From *User `json:"from"` - Chat *chat `json:"chat"` + Chat *Chat `json:"chat"` } type messageResponse struct { @@ -133,14 +133,16 @@ type Update struct { */ } -type chat struct { +// Chat represents a telegram conversation +type Chat struct { ID int64 `json:"id"` Type string `json:"type"` Title string `json:"title"` Username string `json:"username"` } -func (c *chat) Name() string { +// Name returns the name of the channel based on its type +func (c *Chat) Name() string { if c.Type == "private" || c.Type == "channel" { return "@" + c.Username } diff --git a/pkg/services/telegram/telegram_test.go b/pkg/services/telegram/telegram_test.go index bff1fd8e..a0de5b42 100644 --- a/pkg/services/telegram/telegram_test.go +++ b/pkg/services/telegram/telegram_test.go @@ -76,7 +76,6 @@ var _ = Describe("the telegram service", func() { Expect(err).NotTo(HaveOccurred()) err = telegram.Send(message, nil) Expect(err).To(HaveOccurred()) - fmt.Println(err.Error()) Expect(strings.Contains(err.Error(), "401 Unauthorized")).To(BeTrue()) }) }) @@ -160,7 +159,6 @@ func expectErrorAndEmptyObject(telegram *Service, rawURL string, logger *log.Log err := telegram.Initialize(serviceURL, logger) Expect(err).To(HaveOccurred()) config := telegram.GetConfig() - fmt.Printf("Token: \"%+v\" \"%s\" \n", config.Token, config.Token) Expect(config.Token).To(BeEmpty()) Expect(len(config.Chats)).To(BeZero()) } From 281dbdec4ad44ac60586de8cc188e938294d6bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 21:21:59 +0200 Subject: [PATCH 17/22] add general basic tests for services (#192) * test(googlechat): add general basic tests * test(join): add general basic tests * test(mattermost): add general basic tests * exclude XMPP service by default --- generate-service-config-docs.sh | 2 +- pkg/router/servicemap.go | 4 +- pkg/router/servicemap_xmpp.go | 7 +++ pkg/services/googlechat/googlechat_test.go | 50 +++++++++++++++++++- pkg/services/join/join_test.go | 36 ++++++++++++++ pkg/services/mattermost/mattermost_config.go | 13 +++-- pkg/services/mattermost/mattermost_test.go | 45 ++++++++++++++++-- pkg/services/xmpp/xmpp.go | 2 + pkg/services/xmpp/xmpp_config.go | 2 + pkg/services/xmpp/xmpp_test.go | 2 + 10 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 pkg/router/servicemap_xmpp.go diff --git a/generate-service-config-docs.sh b/generate-service-config-docs.sh index 617b6344..b1feeeb3 100755 --- a/generate-service-config-docs.sh +++ b/generate-service-config-docs.sh @@ -20,7 +20,7 @@ fi for S in ./pkg/services/*; do SERVICE=$(basename "$S") - if [[ "$SERVICE" == "standard" ]] || [[ -f "$S" ]]; then + if [[ "$SERVICE" == "standard" ]] || [[ "$SERVICE" == "xmpp" ]] || [[ -f "$S" ]]; then continue fi generate_docs "$SERVICE" diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index af74450d..c3c0f123 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -18,14 +18,13 @@ 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/xmpp" "github.com/containrrr/shoutrrr/pkg/services/zulip" t "github.com/containrrr/shoutrrr/pkg/types" ) var serviceMap = map[string]func() t.Service{ "discord": func() t.Service { return &discord.Service{} }, - "generic": func() t.Service { return &generic.Service{} }, + "generic": func() t.Service { return &generic.Service{} }, "gotify": func() t.Service { return &gotify.Service{} }, "googlechat": func() t.Service { return &googlechat.Service{} }, "hangouts": func() t.Service { return &googlechat.Service{} }, @@ -42,6 +41,5 @@ 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{} }, - "xmpp": func() t.Service { return &xmpp.Service{} }, "zulip": func() t.Service { return &zulip.Service{} }, } diff --git a/pkg/router/servicemap_xmpp.go b/pkg/router/servicemap_xmpp.go new file mode 100644 index 00000000..cf55a001 --- /dev/null +++ b/pkg/router/servicemap_xmpp.go @@ -0,0 +1,7 @@ +//+build xmpp + +package router + +func init() { + serviceMap["xmpp"] = func() t.Service { return &xmpp.Service{} } +} diff --git a/pkg/services/googlechat/googlechat_test.go b/pkg/services/googlechat/googlechat_test.go index 2c8e64a3..64b537f8 100644 --- a/pkg/services/googlechat/googlechat_test.go +++ b/pkg/services/googlechat/googlechat_test.go @@ -1,6 +1,7 @@ package googlechat import ( + "github.com/jarcoal/httpmock" "net/url" "testing" @@ -13,14 +14,59 @@ func TestGooglechat(t *testing.T) { RunSpecs(t, "Shoutrrr Google Chat Suite") } -var _ = Describe("the Googlechat Chat plugin URL building", func() { +var _ = Describe("Google Chat Service", func() { It("should build a valid Google Chat Incoming Webhook URL", func() { configURL, _ := url.Parse("googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz") config := Config{} - config.SetURL(configURL) + Expect(config.SetURL(configURL)).To(Succeed()) expectedURL := "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" Expect(getAPIURL(&config).String()).To(Equal(expectedURL)) }) + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + testURL := "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" + + 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 err error + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should not report an error if the server accepts the payload", func() { + config := Config{ + Host: "chat.googleapis.com", + Path: "v1/spaces/FOO/messages", + Key: "bar", + Token: "baz", + } + serviceURL := config.GetURL() + service := Service{} + err = service.Initialize(serviceURL, nil) + Expect(err).NotTo(HaveOccurred()) + + httpmock.RegisterResponder("POST", "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", httpmock.NewStringResponder(200, ``)) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + + }) }) diff --git a/pkg/services/join/join_test.go b/pkg/services/join/join_test.go index c7732a03..e910ad49 100644 --- a/pkg/services/join/join_test.go +++ b/pkg/services/join/join_test.go @@ -4,6 +4,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/format" "github.com/containrrr/shoutrrr/pkg/services/join" "github.com/containrrr/shoutrrr/pkg/util" + "github.com/jarcoal/httpmock" "net/url" "os" @@ -110,6 +111,41 @@ var _ = Describe("the join config", func() { Expect(fields).To(Equal([]string{"devices", "icon", "title"})) }) }) + + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + input := "join://Token:apikey@join?devices=dev1%2Cdev2&icon=warning&title=hey" + config := &join.Config{} + Expect(config.SetURL(util.URLMust(input))).To(Succeed()) + Expect(config.GetURL().String()).To(Equal(input)) + }) + }) + + Describe("sending the payload", func() { + var err error + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should not report an error if the server accepts the payload", func() { + config := join.Config{ + APIKey: "apikey", + Devices: []string{"dev1"}, + } + serviceURL := config.GetURL() + service := join.Service{} + err = service.Initialize(serviceURL, nil) + Expect(err).NotTo(HaveOccurred()) + + httpmock.RegisterResponder("POST", "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush", httpmock.NewStringResponder(200, ``)) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + + }) }) func createURL(username string, token string, devices string) *url.URL { diff --git a/pkg/services/mattermost/mattermost_config.go b/pkg/services/mattermost/mattermost_config.go index d7da5e9c..53008bab 100644 --- a/pkg/services/mattermost/mattermost_config.go +++ b/pkg/services/mattermost/mattermost_config.go @@ -18,13 +18,18 @@ type Config struct { // GetURL returns a URL representation of it's current field values func (config *Config) GetURL() *url.URL { - path := config.Token - if config.Channel != "" { - path += "/" + config.Channel + paths := []string{"", config.Token, config.Channel} + if config.Channel == "" { + paths = paths[:2] + } + var user *url.Userinfo + if config.UserName != "" { + user = url.User(config.UserName) } return &url.URL{ + User: user, Host: config.Host, - Path: path, + Path: strings.Join(paths, "/"), Scheme: Scheme, ForceQuery: false, } diff --git a/pkg/services/mattermost/mattermost_test.go b/pkg/services/mattermost/mattermost_test.go index e3ad1d7a..c3d9db3b 100644 --- a/pkg/services/mattermost/mattermost_test.go +++ b/pkg/services/mattermost/mattermost_test.go @@ -3,6 +3,7 @@ package mattermost import ( "github.com/containrrr/shoutrrr/pkg/types" "github.com/containrrr/shoutrrr/pkg/util" + "github.com/jarcoal/httpmock" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "net/url" @@ -31,7 +32,7 @@ var _ = Describe("the mattermost service", func() { return } serviceURL, _ := url.Parse(envMattermostURL.String()) - service.Initialize(serviceURL, util.TestLogger()) + Expect(service.Initialize(serviceURL, util.TestLogger())).To(Succeed()) err := service.Send( "this is an integration test", nil, @@ -113,7 +114,7 @@ var _ = Describe("the mattermost service", func() { When("sending a message completely without parameters", func() { mattermostURL, _ := url.Parse("mattermost://mattermost.my-domain.com/thisshouldbeanapitoken") config := &Config{} - config.SetURL(mattermostURL) + Expect(config.SetURL(mattermostURL)).To(Succeed()) It("should generate the correct url to call", func() { generatedURL := buildURL(config) Expect(generatedURL).To(Equal("https://mattermost.my-domain.com/hooks/thisshouldbeanapitoken")) @@ -127,7 +128,7 @@ var _ = Describe("the mattermost service", func() { When("sending a message with pre set username and channel", func() { mattermostURL, _ := url.Parse("mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel") config := &Config{} - config.SetURL(mattermostURL) + Expect(config.SetURL(mattermostURL)).To(Succeed()) It("should generate the correct JSON body", func() { json, err := CreateJSONPayload(config, "this is a message", nil) Expect(err).NotTo(HaveOccurred()) @@ -137,7 +138,7 @@ var _ = Describe("the mattermost service", func() { When("sending a message with pre set username and channel but overwriting them with parameters", func() { mattermostURL, _ := url.Parse("mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel") config := &Config{} - config.SetURL(mattermostURL) + Expect(config.SetURL(mattermostURL)).To(Succeed()) It("should generate the correct JSON body", func() { params := (*types.Params)(&map[string]string{"username": "overwriteUserName", "channel": "overwriteChannel"}) json, err := CreateJSONPayload(config, "this is a message", params) @@ -146,4 +147,40 @@ var _ = Describe("the mattermost service", func() { }) }) }) + + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + input := "mattermost://bot@mattermost.host/token/channel" + + config := &Config{} + Expect(config.SetURL(util.URLMust(input))).To(Succeed()) + Expect(config.GetURL().String()).To(Equal(input)) + }) + }) + + Describe("sending the payload", func() { + var err error + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should not report an error if the server accepts the payload", func() { + config := Config{ + Host: "mattermost.host", + Token: "token", + } + serviceURL := config.GetURL() + service := Service{} + err = service.Initialize(serviceURL, nil) + Expect(err).NotTo(HaveOccurred()) + + httpmock.RegisterResponder("POST", "https://mattermost.host/hooks/token", httpmock.NewStringResponder(200, ``)) + + err = service.Send("Message", nil) + Expect(err).NotTo(HaveOccurred()) + }) + + }) }) diff --git a/pkg/services/xmpp/xmpp.go b/pkg/services/xmpp/xmpp.go index 2ec84745..8d73afce 100644 --- a/pkg/services/xmpp/xmpp.go +++ b/pkg/services/xmpp/xmpp.go @@ -1,3 +1,5 @@ +//+build xmpp + package xmpp import ( diff --git a/pkg/services/xmpp/xmpp_config.go b/pkg/services/xmpp/xmpp_config.go index 2b2a0588..4583d25a 100644 --- a/pkg/services/xmpp/xmpp_config.go +++ b/pkg/services/xmpp/xmpp_config.go @@ -1,3 +1,5 @@ +//+build xmpp + package xmpp import ( diff --git a/pkg/services/xmpp/xmpp_test.go b/pkg/services/xmpp/xmpp_test.go index 296c92a6..ee4f2f58 100644 --- a/pkg/services/xmpp/xmpp_test.go +++ b/pkg/services/xmpp/xmpp_test.go @@ -1,3 +1,5 @@ +//+build xmpp + package xmpp import ( From b8681dc48720ccbf7eea24b8de3f6008040ea0b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 22:19:17 +0200 Subject: [PATCH 18/22] test: additional format tests/cleanup (#193) - add tests for rendering enum values and aliases for `MarkdownRenderer` - remove unused value rendering code from `MarkdownRenderer` - add tests for URLPart (probably of limited usefulness?) - removed duplicate interface `Renderer` (existed as `TreeRenderer`) --- pkg/format/render_console_test.go | 16 ++-------- pkg/format/render_markdown.go | 49 ------------------------------ pkg/format/render_markdown_test.go | 49 ++++++++++++++++++++++++------ pkg/format/render_test.go | 17 +++++++++++ pkg/format/renderer.go | 11 ------- pkg/format/urlpart_test.go | 34 +++++++++++++++++++++ 6 files changed, 93 insertions(+), 83 deletions(-) create mode 100644 pkg/format/render_test.go delete mode 100644 pkg/format/renderer.go create mode 100644 pkg/format/urlpart_test.go diff --git a/pkg/format/render_console_test.go b/pkg/format/render_console_test.go index 5587bd9f..e5b0ab4b 100644 --- a/pkg/format/render_console_test.go +++ b/pkg/format/render_console_test.go @@ -11,7 +11,7 @@ var _ = Describe("RenderConsole", func() { renderer := ConsoleTreeRenderer{WithValues: false} It("should render the expected output based on config reflection/tags", func() { - actual := testRenderTee(renderer, &struct { + actual := testRenderTree(renderer, &struct { Name string `default:"notempty"` Host string `url:"host"` }{}) @@ -20,14 +20,12 @@ var _ = Describe("RenderConsole", func() { Host string Name string `[1:] - println() - println(actual) Expect(actual).To(Equal(expected)) }) It("should render url paths in sorted order", func() { - actual := testRenderTee(renderer, &struct { + actual := testRenderTree(renderer, &struct { Host string `url:"host"` Path1 string `url:"path1"` Path3 string `url:"path3"` @@ -41,16 +39,6 @@ Path2 string Path3 string `[1:] - println() - println(actual) - Expect(actual).To(Equal(expected)) }) }) - -/* - -* __TestEnum__ - Default: `+"`None`"+` - Possible values: `+"`None`, `Foo`, `Bar`"+` -*/ diff --git a/pkg/format/render_markdown.go b/pkg/format/render_markdown.go index 07f420c0..380bbd61 100644 --- a/pkg/format/render_markdown.go +++ b/pkg/format/render_markdown.go @@ -4,8 +4,6 @@ import ( "reflect" "sort" "strings" - - "github.com/containrrr/shoutrrr/pkg/util" ) // MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string @@ -196,53 +194,6 @@ func (MarkdownTreeRenderer) writeFieldPrimary(sb *strings.Builder, field *FieldI } } -func (r MarkdownTreeRenderer) writeNodeValue(sb *strings.Builder, node Node) int { - if contNode, isContainer := node.(*ContainerNode); isContainer { - return r.writeContainer(sb, contNode) - } - - if valNode, isValue := node.(*ValueNode); isValue { - sb.WriteString(valNode.Value) - return len(valNode.Value) - } - - sb.WriteRune('?') - return 1 -} - -func (r MarkdownTreeRenderer) writeContainer(sb *strings.Builder, node *ContainerNode) int { - kind := node.Type.Kind() - - hasKeys := !util.IsCollection(kind) - - totalLen := 4 - if hasKeys { - sb.WriteString("{ ") - } else { - sb.WriteString("[ ") - } - for i, itemNode := range node.Items { - if i != 0 { - sb.WriteString(", ") - totalLen += 2 - } - if hasKeys { - itemKey := itemNode.Field().Name - sb.WriteString(itemKey) - sb.WriteString(": ") - totalLen += len(itemKey) + 2 - } - valLen := r.writeNodeValue(sb, itemNode) - totalLen += valLen - } - if hasKeys { - sb.WriteString(" }") - } else { - sb.WriteString(" ]") - } - return totalLen -} - func (r MarkdownTreeRenderer) writeHeader(sb *strings.Builder, text string) { sb.WriteString(r.HeaderPrefix) sb.WriteString(text) diff --git a/pkg/format/render_markdown_test.go b/pkg/format/render_markdown_test.go index adc8ac33..641a6183 100644 --- a/pkg/format/render_markdown_test.go +++ b/pkg/format/render_markdown_test.go @@ -7,10 +7,10 @@ import ( ) var _ = Describe("RenderMarkdown", func() { - gformat.CharactersAroundMismatchToInclude = 30 + gformat.CharactersAroundMismatchToInclude = 10 It("should render the expected output based on config reflection/tags", func() { - actual := testRenderTee(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { Name string `default:"notempty"` Host string `url:"host"` }{}) @@ -32,7 +32,7 @@ var _ = Describe("RenderMarkdown", func() { }) It("should render url paths in sorted order", func() { - actual := testRenderTee(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { Host string `url:"host"` Path1 string `url:"path1"` Path3 string `url:"path3"` @@ -57,11 +57,42 @@ var _ = Describe("RenderMarkdown", func() { Expect(actual).To(Equal(expected)) }) -}) -/* + It("should render prop aliases", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Name string `key:"name,handle,title,target"` + }{}) + + expected := ` +### URL Fields + +### Query/Param Props + + +* __Name__ (**Required**) + Aliases: `[1:] + "`handle`, `title`, `target`" + ` + +` + + Expect(actual).To(Equal(expected)) + }) + + It("should render possible enum values", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &testEnummer{}) + + expected := ` +### URL Fields + +### Query/Param Props -* __TestEnum__ - Default: `+"`None`"+` - Possible values: `+"`None`, `Foo`, `Bar`"+` -*/ + +* __Choice__ + Default: `[1:] + "`Maybe`" + ` + Possible values: ` + "`Yes`, `No`, `Maybe`" + ` + +` + println() + println(actual) + Expect(actual).To(Equal(expected)) + }) +}) diff --git a/pkg/format/render_test.go b/pkg/format/render_test.go new file mode 100644 index 00000000..414e2ceb --- /dev/null +++ b/pkg/format/render_test.go @@ -0,0 +1,17 @@ +package format + +import t "github.com/containrrr/shoutrrr/pkg/types" + +type testEnummer struct { + Choice int `key:"choice" default:"Maybe"` +} + +func (testEnummer) Enums() map[string]t.EnumFormatter { + return map[string]t.EnumFormatter{ + "Choice": CreateEnumFormatter([]string{"Yes", "No", "Maybe"}), + } +} + +func testRenderTree(r TreeRenderer, v interface{}) string { + return r.RenderTree(getRootNode(v), "mock") +} diff --git a/pkg/format/renderer.go b/pkg/format/renderer.go deleted file mode 100644 index 369c7ff9..00000000 --- a/pkg/format/renderer.go +++ /dev/null @@ -1,11 +0,0 @@ -package format - -// Renderer renders a supplied node tree to a string -type Renderer interface { - // RenderTree renders a ContainerNode tree into a ansi-colored console string - RenderTree(root *ContainerNode, scheme string) string -} - -func testRenderTee(r Renderer, v interface{}) string { - return r.RenderTree(getRootNode(v), "mock") -} diff --git a/pkg/format/urlpart_test.go b/pkg/format/urlpart_test.go new file mode 100644 index 00000000..c10df537 --- /dev/null +++ b/pkg/format/urlpart_test.go @@ -0,0 +1,34 @@ +package format_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + . "github.com/containrrr/shoutrrr/pkg/format" +) + +var _ = Describe("URLPart", func() { + It("should return the expected URL part for each lookup key", func() { + Expect(ParseURLPart("user")).To(Equal(URLUser)) + Expect(ParseURLPart("pass")).To(Equal(URLPassword)) + Expect(ParseURLPart("password")).To(Equal(URLPassword)) + Expect(ParseURLPart("host")).To(Equal(URLHost)) + Expect(ParseURLPart("port")).To(Equal(URLPort)) + + Expect(ParseURLPart("path")).To(Equal(URLPath)) + Expect(ParseURLPart("path1")).To(Equal(URLPath)) + Expect(ParseURLPart("path2")).To(Equal(URLPath + 1)) + Expect(ParseURLPart("path3")).To(Equal(URLPath + 2)) + Expect(ParseURLPart("path4")).To(Equal(URLPath + 3)) + + Expect(ParseURLPart("query")).To(Equal(URLQuery)) + Expect(ParseURLPart("")).To(Equal(URLQuery)) + }) + It("should return the expected suffix for each URL part", func() { + Expect(URLUser.Suffix()).To(Equal(':')) + Expect(URLPassword.Suffix()).To(Equal('@')) + Expect(URLHost.Suffix()).To(Equal(':')) + Expect(URLPort.Suffix()).To(Equal('/')) + Expect(URLPath.Suffix()).To(Equal('/')) + }) +}) From 42478d212583236261a82243449dc3da5f418808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Tue, 10 Aug 2021 22:31:11 +0200 Subject: [PATCH 19/22] docs: fix references to `master` branch --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e0adaefe..6f8b658f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- + # Shoutrrr @@ -10,12 +10,12 @@ Notification library for gophers and their furry friends. Heavily inspired by caronc/apprise. ![github actions workflow status](https://github.com/containrrr/shoutrrr/workflows/Main%20Workflow/badge.svg) -[![codecov](https://codecov.io/gh/containrrr/shoutrrr/branch/master/graph/badge.svg)](https://codecov.io/gh/containrrr/shoutrrr) +[![codecov](https://codecov.io/gh/containrrr/shoutrrr/branch/main/graph/badge.svg)](https://codecov.io/gh/containrrr/shoutrrr) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/47eed72de79448e2a6e297d770355544)](https://www.codacy.com/gh/containrrr/shoutrrr/dashboard?utm_source=github.com&utm_medium=referral&utm_content=containrrr/shoutrrr&utm_campaign=Badge_Grade) [![report card](https://goreportcard.com/badge/github.com/containrrr/shoutrrr)](https://goreportcard.com/badge/github.com/containrrr/shoutrrr) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/containrrr/shoutrrr) [![github code size in bytes](https://img.shields.io/github/languages/code-size/containrrr/shoutrrr.svg?style=flat-square)](https://github.com/containrrr/shoutrrr) -[![license](https://img.shields.io/github/license/containrrr/shoutrrr.svg?style=flat-square)](https://github.com/containrrr/shoutrrr/blob/master/LICENSE) +[![license](https://img.shields.io/github/license/containrrr/shoutrrr.svg?style=flat-square)](https://github.com/containrrr/shoutrrr/blob/main/LICENSE) [![godoc](https://godoc.org/github.com/containrrr/shoutrrr?status.svg)](https://godoc.org/github.com/containrrr/shoutrrr) [![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-) From 5c378b4561d172ef1495debb6726ef384ba108cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Wed, 11 Aug 2021 10:17:37 +0200 Subject: [PATCH 20/22] ci(main): add manual dispatch --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15b093d7..e9ad50c0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,7 @@ name: Main Workflow on: pull_request: {} + workflow_dispatch: {} push: branches: - '*' From 898d90800c3d96f1173fe913e5e6b68a13bfd6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Mon, 16 Aug 2021 12:15:33 +0200 Subject: [PATCH 21/22] feat(telegram): add title support (#196) --- docs/services/telegram.md | 17 ++++++++++++-- .../telegram/telegram_internal_test.go | 23 ++++++++++++++++--- pkg/services/telegram/telegram_json.go | 21 +++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/services/telegram.md b/docs/services/telegram.md index a276e358..bdea600f 100644 --- a/docs/services/telegram.md +++ b/docs/services/telegram.md @@ -14,8 +14,21 @@ Talk to [the botfather](https://core.telegram.org/bots#6-botfather). ## Optional parameters You can optionally specify the __`notification`__, __`parseMode`__ and __`preview`__ parameters in the URL: -*telegram://__`token`__@__`telegram`__/?channels=__`channel`__¬ification=no&preview=false&parseMode=markDownv2* + +!!! info "" +
telegram://__`token`__@__`telegram`__/?channels=__`channel`__¬ification=no&preview=false&parseMode=html
See [the telegram documentation](https://core.telegram.org/bots/api#sendmessage) for more information. -__Note:__ `preview` and `notification` are inverted in regards to their API counterparts (`disable_web_page_preview` and `disable_notification`) \ No newline at end of file +!!! note + `preview` and `notification` are inverted in regards to their API counterparts (`disable_web_page_preview` and `disable_notification`) + +### Parse Mode and Title + +If a parse mode is specified, the message needs to be escaped as per the corresponding sections in +[Formatting options](https://core.telegram.org/bots/api#formatting-options). + +When a title has been specified, it will be prepended to the message, but this is only supported for +the `HTML` parse mode. Note that, if no parse mode is specified, the message will be escaped and sent using `HTML`. + +Since the markdown modes are really hard to escape correctly, it's recommended to stick to `HTML` parse mode. \ No newline at end of file diff --git a/pkg/services/telegram/telegram_internal_test.go b/pkg/services/telegram/telegram_internal_test.go index bdbe652d..d8509f7e 100644 --- a/pkg/services/telegram/telegram_internal_test.go +++ b/pkg/services/telegram/telegram_internal_test.go @@ -36,10 +36,27 @@ var _ = Describe("the telegram service", func() { }) }) When("it's set to None", func() { - It("no parse_mode should be present in payload", func() { - payload, err := getPayloadStringFromURL("telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=None", "Message", logger) + When("no title has been provided", func() { + It("no parse_mode should be present in payload", func() { + payload, err := getPayloadStringFromURL("telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=None", "Message", logger) + Expect(err).NotTo(HaveOccurred()) + Expect(payload).NotTo(ContainSubstring("parse_mode")) + }) + }) + When("a title has been provided", func() { + payload, err := getPayloadFromURL("telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle", `Oh wow! <3 Cool & stuff ->`, logger) Expect(err).NotTo(HaveOccurred()) - Expect(payload).NotTo(ContainSubstring("parse_mode")) + It("should have parse_mode set to HTML", func() { + Expect(payload.ParseMode).To(Equal("HTML")) + }) + It("should contain the title prepended in the message", func() { + Expect(payload.Text).To(ContainSubstring("MessageTitle")) + }) + It("should escape the message HTML tags", func() { + Expect(payload.Text).To(ContainSubstring("<3")) + Expect(payload.Text).To(ContainSubstring("Cool & stuff")) + Expect(payload.Text).To(ContainSubstring("->")) + }) }) }) }) diff --git a/pkg/services/telegram/telegram_json.go b/pkg/services/telegram/telegram_json.go index 144b3e34..87714a98 100644 --- a/pkg/services/telegram/telegram_json.go +++ b/pkg/services/telegram/telegram_json.go @@ -1,5 +1,10 @@ package telegram +import ( + "fmt" + "html" +) + // SendMessagePayload is the notification payload for the telegram notification service type SendMessagePayload struct { Text string `json:"text"` @@ -34,8 +39,20 @@ func createSendMessagePayload(message string, channel string, config *Config) Se DisablePreview: !config.Preview, } - if config.ParseMode != ParseModes.None { - payload.ParseMode = config.ParseMode.String() + parseMode := config.ParseMode + if config.ParseMode == ParseModes.None && config.Title != "" { + parseMode = ParseModes.HTML + // no parse mode has been provided, treat message as unescaped HTML + message = html.EscapeString(message) + } + + if parseMode != ParseModes.None { + payload.ParseMode = parseMode.String() + } + + // only HTML parse mode is supported for titles + if parseMode == ParseModes.HTML { + payload.Text = fmt.Sprintf("%v\n%v", html.EscapeString(config.Title), message) } return payload From ba244556b301a437e620de990c52bb88bacaa277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Mon, 16 Aug 2021 12:49:16 +0200 Subject: [PATCH 22/22] fix(docs): replace props desc when empty (#197) --- cli/cmd/docs/docs.go | 5 ++- docs/index.md | 8 ++-- pkg/format/render_markdown.go | 11 ++++-- pkg/format/render_markdown_test.go | 59 +++++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/cli/cmd/docs/docs.go b/cli/cmd/docs/docs.go index 0a19b0ca..5bfc195f 100644 --- a/cli/cmd/docs/docs.go +++ b/cli/cmd/docs/docs.go @@ -51,8 +51,9 @@ func printDocs(format string, services []string) cli.Result { renderer = f.ConsoleTreeRenderer{WithValues: false} case "markdown": renderer = f.MarkdownTreeRenderer{ - HeaderPrefix: "### ", - PropsDescription: "Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n", + HeaderPrefix: "### ", + PropsDescription: "Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n", + PropsEmptyMessage: "*The services does not support any query/param props*", } default: return cli.InvalidUsage("invalid format") diff --git a/docs/index.md b/docs/index.md index 2a472e53..17d5ea54 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ # Shoutrrr
- +

@@ -14,7 +14,7 @@ Heavily inspired by caronc/apprise - codecov + codecov Codacy Badge @@ -28,7 +28,7 @@ Heavily inspired by caronc/apprise github code size in bytes - + license

@@ -37,4 +37,4 @@ Heavily inspired by caronc/apprise 0 { + sb.WriteString(r.PropsDescription) + } else { + sb.WriteString(r.PropsEmptyMessage) + } sb.WriteRune('\n') for _, field := range queryFields { r.writeFieldPrimary(&sb, field) diff --git a/pkg/format/render_markdown_test.go b/pkg/format/render_markdown_test.go index 641a6183..39051f97 100644 --- a/pkg/format/render_markdown_test.go +++ b/pkg/format/render_markdown_test.go @@ -91,8 +91,63 @@ var _ = Describe("RenderMarkdown", func() { Possible values: ` + "`Yes`, `No`, `Maybe`" + ` ` - println() - println(actual) + Expect(actual).To(Equal(expected)) }) + + When("there are no query props", func() { + It("should prepend an empty-message instead of props description", func() { + actual := testRenderTree(MarkdownTreeRenderer{ + HeaderPrefix: `### `, + PropsDescription: "Feel free to set these:", + PropsEmptyMessage: "There is nothing to set!", + }, &struct { + Host string `url:"host"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + +There is nothing to set! +`[1:] // Remove initial newline + + //_ = actual == expected + //println() + //println(actual) + Expect(actual).To(Equal(expected)) + }) + }) + + When("there are query props", func() { + It("should prepend the props description", func() { + actual := testRenderTree(MarkdownTreeRenderer{ + HeaderPrefix: `### `, + PropsDescription: "Feel free to set these:", + PropsEmptyMessage: "There is nothing to set!", + }, &struct { + Host string `url:"host"` + CoolMode bool `key:"coolmode" optional:""` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + +Feel free to set these: +* __CoolMode__ + Default: *empty* + +`[1:] // Remove initial newline + + Expect(actual).To(Equal(expected)) + }) + }) + })