Repository: containrrr/watchtower (go)

Issue:
Don't add timestamps to shoutrrr notifications
One feature I’m missing while testing the new notifications on latest-dev is to disable the timestamps. Since I think most of the services show timestamps anyway, I think there should be an option to disable and/or format them in watchtower.

_Originally posted by @mbrandau in https://github.com/containrrr/watchtower/issues/264#issuecomment-612574815_

Pull request description:
feat: add template support for shoutrrr notifications
In https://github.com/containrrr/watchtower/issues/264#issuecomment-612574815 @mbrandau suggested to make timestamp configurable in shoutrrr notifications. The idea has been added as a feature request in https://github.com/containrrr/watchtower/issues/502.

In https://github.com/containrrr/watchtower/issues/264#issuecomment-616231162 I suggested to maybe introduce a template for the messages.

This change introduces a template as a configuration for shoutrrr  notifications.

~~It still missed documentation etc. But I wanted to see what you think of the idea before going ahead.~~

*edit by @simskij:*
resolves #502 

Patch:
diff --git a/docs/notifications.md b/docs/notifications.md
index 7672c19..de3c505 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -179,6 +179,14 @@ To send notifications via shoutrrr, the following command-line options, or their
 Go to [containrrr.github.io/shoutrrr/services/overview](https://containrrr.github.io/shoutrrr/services/overview) to learn more about the different service URLs you can use.
 You can define multiple services by space separating the URLs. (See example below)
 
+You can customize the message posted by setting a template.
+
+- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
+
+The template is a Go [template](https://golang.org/pkg/text/template/) and the you format a list of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry).
+
+The default value if not set is `{{range .}}{{.Message}}{{println}}{{end}}`. The example below uses a template that also outputs timestamp and log level.
+
 Example:
 
 ```bash
@@ -187,5 +195,6 @@ docker run -d \
   -v /var/run/docker.sock:/var/run/docker.sock \
   -e WATCHTOWER_NOTIFICATIONS=shoutrrr \
   -e WATCHTOWER_NOTIFICATION_URL="discord://token@channel slack://watchtower@token-a/token-b/token-c" \
+  -e WATCHTOWER_NOTIFICATION_TEMPLATE="{{range .}}{{.Time.Format \"2006-01-02 15:04:05\"}} ({{.Level}}): {{.Message}}{{println}}{{end}}" \
   containrrr/watchtower
 ```
\ No newline at end of file
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 20e2cfb..9554a3c 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -259,6 +259,12 @@ Should only be used for testing.
 		viper.GetString("WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN"),
 		"The Gotify Application required to query the Gotify API")
 
+	flags.StringP(
+		"notification-template",
+		"",
+		viper.GetString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
+		"The shoutrrr text/template for the messages")
+
 	flags.StringArrayP(
 		"notification-url",
 		"",
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 108e3b6..04fbf8b 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -1,7 +1,10 @@
 package notifications
 
 import (
+	"bytes"
 	"fmt"
+	"text/template"
+
 	"github.com/containrrr/shoutrrr"
 	"github.com/containrrr/shoutrrr/pkg/router"
 	t "github.com/containrrr/watchtower/pkg/types"
@@ -10,7 +13,8 @@ import (
 )
 
 const (
-	shoutrrrType = "shoutrrr"
+	shoutrrrDefaultTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
+	shoutrrrType            = "shoutrrr"
 )
 
 // Implements Notifier, logrus.Hook
@@ -19,6 +23,7 @@ type shoutrrrTypeNotifier struct {
 	Router    *router.ServiceRouter
 	entries   []*log.Entry
 	logLevels []log.Level
+	template  *template.Template
 }
 
 func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
@@ -31,6 +36,7 @@ func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Noti
 		Urls:      urls,
 		Router:    r,
 		logLevels: acceptedLogLevels,
+		template:  getShoutrrrTemplate(c),
 	}
 
 	log.AddHook(n)
@@ -39,13 +45,10 @@ func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Noti
 }
 
 func (e *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
-	body := ""
-	for _, entry := range entries {
-		body += entry.Time.Format("2006-01-02 15:04:05") + " (" + entry.Level.String() + "): " + entry.Message + "\r\n"
-		// We don't use fields in watchtower, so don't bother sending them.
-	}
+	var body bytes.Buffer
+	e.template.Execute(&body, entries)
 
-	return body
+	return body.String()
 }
 
 func (e *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
@@ -91,3 +94,35 @@ func (e *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
 	}
 	return nil
 }
+
+func getShoutrrrTemplate(c *cobra.Command) *template.Template {
+	var tpl *template.Template
+
+	flags := c.PersistentFlags()
+
+	tplString, err := flags.GetString("notification-template")
+
+	// If we succeed in getting a non-empty template configuration
+	// try to parse the template string.
+	if tplString != "" && err == nil {
+		tpl, err = template.New("").Parse(tplString)
+	}
+
+	// In case of errors (either from parsing the template string
+	// or from getting the template configuration) log an error
+	// message about this and the fact that we'll use the default
+	// template instead.
+	if err != nil {
+		log.Errorf("Could not use configured notification template: %s. Using default template", err)
+	}
+
+	// If we had an error (either from parsing the template string
+	// or from getting the template configuration) or we a
+	// template wasn't configured (the empty template string)
+	// fallback to using the default template.
+	if err != nil || tplString == "" {
+		tpl = template.Must(template.New("").Parse(shoutrrrDefaultTemplate))
+	}
+
+	return tpl
+}


Test patch:
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
new file mode 100644
index 0000000..9795c2a
--- /dev/null
+++ b/pkg/notifications/shoutrrr_test.go
@@ -0,0 +1,80 @@
+package notifications
+
+import (
+	"testing"
+	"text/template"
+
+	"github.com/containrrr/watchtower/internal/flags"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/require"
+)
+
+func TestShoutrrrDefaultTemplate(t *testing.T) {
+	cmd := new(cobra.Command)
+
+	shoutrrr := &shoutrrrTypeNotifier{
+		template: getShoutrrrTemplate(cmd),
+	}
+
+	entries := []*log.Entry{
+		{
+			Message: "foo bar",
+		},
+	}
+
+	s := shoutrrr.buildMessage(entries)
+
+	require.Equal(t, "foo bar\n", s)
+}
+
+func TestShoutrrrTemplate(t *testing.T) {
+	cmd := new(cobra.Command)
+	flags.RegisterNotificationFlags(cmd)
+	err := cmd.ParseFlags([]string{"--notification-template={{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}"})
+
+	require.NoError(t, err)
+
+	shoutrrr := &shoutrrrTypeNotifier{
+		template: getShoutrrrTemplate(cmd),
+	}
+
+	entries := []*log.Entry{
+		{
+			Level:   log.InfoLevel,
+			Message: "foo bar",
+		},
+	}
+
+	s := shoutrrr.buildMessage(entries)
+
+	require.Equal(t, "info: foo bar\n", s)
+}
+
+func TestShoutrrrInvalidTemplateUsesTemplate(t *testing.T) {
+	cmd := new(cobra.Command)
+
+	flags.RegisterNotificationFlags(cmd)
+	err := cmd.ParseFlags([]string{"--notification-template={{"})
+
+	require.NoError(t, err)
+
+	shoutrrr := &shoutrrrTypeNotifier{
+		template: getShoutrrrTemplate(cmd),
+	}
+
+	shoutrrrDefault := &shoutrrrTypeNotifier{
+		template: template.Must(template.New("").Parse(shoutrrrDefaultTemplate)),
+	}
+
+	entries := []*log.Entry{
+		{
+			Message: "foo bar",
+		},
+	}
+
+	s := shoutrrr.buildMessage(entries)
+	sd := shoutrrrDefault.buildMessage(entries)
+
+	require.Equal(t, sd, s)
+}