Repository: containrrr/watchtower (go)

Issue:
Discord/Slack notifications being sent for runs with no updates
<!--
  Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image.
  If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported.
-->

**Describe the bug**
<!-- A clear and concise description of what the bug is. -->

On the latest dev image and for the last week, I noticed that Watchtower is now sending notifications for every single run, even if there are no updates. Previously, it would only send a notification if a container was actually updated. This is creating even more notification spam than usual (I was hoping Watchtower would go the other way where notifications became limited to summaries of runs where containers were updated).

**To Reproduce**
<!--
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->

1. Use latest dev version of Watchtower and configure Discord notifications. Not sure if this issue exists for Slack or Shoutrr as well, but I am using Discord via the Slack configuration variables (which works outside this issue).

```yaml
  watchtower:
    image: containrrr/watchtower:latest-dev
    container_name: watchtower
    environment:
      - TZ=${TZ}
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_REMOVE_VOLUMES=true
      - WATCHTOWER_SCHEDULE=0 0 * * * *
      - WATCHTOWER_INCLUDE_STOPPED=true
      - WATCHTOWER_INCLUDE_RESTARTING=true
      - WATCHTOWER_NOTIFICATIONS=slack
      - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=https://discord.com/api/webhooks/${WATCHTOWERWEBHOOK}/slack
      - WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER=Watchtower
      - "WATCHTOWER_NOTIFICATION_SLACK_ICON_EMOJI=:whale:"
      - WATCHTOWER_NOTIFICATION_SLACK_ICON_URL=https://raw.githubusercontent.com/containrrr/watchtower/master/logo.png
      - DOCKER_CONFIG=/config
    volumes:
      - ${CONFDIR}/watchtower:/config
      - /var/run/docker.sock:/var/run/docker.sock
    hostname: ${HOSTNAME}
    logging: *default-logging
    labels: *default-labels
    restart: unless-stopped
```
2. Wait for a run without any updates, check notifications    

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->

Notifications are only sent if a container is being updated on a run

**Screenshots**
<!--
If applicable, add screenshots to help explain your problem.
-->
![image](https://user-images.githubusercontent.com/39887349/123996236-bb8e8980-d994-11eb-969f-c85cd24e8c85.png)
**Environment**
<!-- 
We want to know:
 - Platform
 - Architecture
 - Docker version 
-->
`latest-dev` tag, currently running version `v1.3.0-19-gea300b7`


Pull request:
fix(notifications): default templates and logic
Follow up/continuation of #981.

This PR will fix the default report template to only output something if `Updated` or `Failed` is non-zero. It also fixes the non-session notifications (like the startup info) which was just broken using the report notifications.
Note that this aligns the notification behaviour roughly with how it works using legacy notifications, as no notifications will be sent when everything is "normal"/already updated.

<details>

<summary><strong>the new default report template</strong></summary>

```go
{{- if .Report -}}
  {{- with .Report -}}
    {{- if ( or .Updated .Failed ) -}}
{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
      {{- range .Updated}}
- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
      {{- end -}}
      {{- range .Fresh}}
- {{.Name}} ({{.ImageName}}): {{.State}}
      {{- end -}}
      {{- range .Skipped}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
      {{- end -}}
      {{- range .Failed}}
- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
      {{- end -}}
    {{- end -}}
  {{- end -}}
{{- else -}}
  {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
{{end -}}
```

</details>

To prevent the notifications from being sent, it skips notifications whenever the output of the template (whether legacy or report templates) is an empty string.
This fixes #1008 

It also adds a "special" data field for logging that just marks a message as non-notification. This is so that we can still log using the appropriate log levels without having to worry about it getting sent as notifications.
This mainly concerns logs about the notifications themselves, which would cause loops etc. 
Any log messages with the field `notify` set to `no` is ignored.

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 58246fd..afe73da 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -767,6 +767,15 @@
       "contributions": [
         "doc"
       ]
+    },
+    {
+      "login": "djquan",
+      "name": "Dan Quan",
+      "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4",
+      "profile": "https://quan.io",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 8a18a58..a51b266 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@
   [![Circle CI](https://circleci.com/gh/containrrr/watchtower.svg?style=shield)](https://circleci.com/gh/containrrr/watchtower)
   [![codecov](https://codecov.io/gh/containrrr/watchtower/branch/main/graph/badge.svg)](https://codecov.io/gh/containrrr/watchtower)
   [![GoDoc](https://godoc.org/github.com/containrrr/watchtower?status.svg)](https://godoc.org/github.com/containrrr/watchtower)
-  [![Microbadger](https://images.microbadger.com/badges/image/containrrr/watchtower.svg)](https://microbadger.com/images/containrrr/watchtower)
   [![Go Report Card](https://goreportcard.com/badge/github.com/containrrr/watchtower)](https://goreportcard.com/report/github.com/containrrr/watchtower)
   [![latest version](https://img.shields.io/github/tag/containrrr/watchtower.svg)](https://github.com/containrrr/watchtower/releases)
   [![Apache-2.0 License](https://img.shields.io/github/license/containrrr/watchtower.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -146,6 +145,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
     <td align="center"><a href="https://reinout.vanrees.org"><img src="https://avatars.githubusercontent.com/u/121433?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Reinout van Rees</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=reinout" title="Documentation">📖</a></td>
     <td align="center"><a href="https://github.com/DasSkelett"><img src="https://avatars.githubusercontent.com/u/28812678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DasSkelett</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=DasSkelett" title="Code">💻</a></td>
     <td align="center"><a href="https://github.com/zenjabba"><img src="https://avatars.githubusercontent.com/u/679864?v=4?s=100" width="100px;" alt=""/><br /><sub><b>zenjabba</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zenjabba" title="Documentation">📖</a></td>
+    <td align="center"><a href="https://quan.io"><img src="https://avatars.githubusercontent.com/u/3526705?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dan Quan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=djquan" title="Documentation">📖</a></td>
   </tr>
 </table>
 
diff --git a/cmd/root.go b/cmd/root.go
index fb7c29b..47f7133 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"github.com/containrrr/watchtower/internal/meta"
 	"math"
+	"net/http"
 	"os"
 	"os/signal"
 	"strconv"
@@ -197,7 +198,7 @@ func Run(c *cobra.Command, names []string) {
 		httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
 	}
 
-	if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil {
+	if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && err != http.ErrServerClosed {
 		log.Error("failed to start API", err)
 	}
 
@@ -259,24 +260,43 @@ func formatDuration(d time.Duration) string {
 }
 
 func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
-	if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
-		schedMessage := "Running a one time update."
-		if !sched.IsZero() {
-			until := formatDuration(time.Until(sched))
-			schedMessage = "Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST") +
-				"\nNote that the first check will be performed in " + until
-		}
-
-		notifs := "Using no notifications"
-		notifierNames := notifier.GetNames()
-		if len(notifierNames) > 0 {
-			notifs = "Using notifications: " + strings.Join(notifierNames, ", ")
-		}
-
-		log.Info("Watchtower ", meta.Version, "\n", notifs, "\n", filtering, "\n", schedMessage)
-		if log.IsLevelEnabled(log.TraceLevel) {
-			log.Warn("trace level enabled: log will include sensitive information as credentials and tokens")
-		}
+	noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message")
+
+	var startupLog *log.Entry
+	if noStartupMessage {
+		startupLog = notifications.LocalLog
+	} else {
+		startupLog = log.NewEntry(log.StandardLogger())
+		// Batch up startup messages to send them as a single notification
+		notifier.StartNotification()
+	}
+
+	startupLog.Info("Watchtower ", meta.Version)
+
+	notifierNames := notifier.GetNames()
+	if len(notifierNames) > 0 {
+		startupLog.Info("Using notifications: " + strings.Join(notifierNames, ", "))
+	} else {
+		startupLog.Info("Using no notifications")
+	}
+
+	startupLog.Info(filtering)
+
+	if !sched.IsZero() {
+		until := formatDuration(time.Until(sched))
+		startupLog.Info("Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST"))
+		startupLog.Info("Note that the first check will be performed in " + until)
+	} else {
+		startupLog.Info("Running a one time update.")
+	}
+
+	if !noStartupMessage {
+		// Send the queued up startup messages, not including the trace warning below (to make sure it's noticed)
+		notifier.SendNotification(nil)
+	}
+
+	if log.IsLevelEnabled(log.TraceLevel) {
+		startupLog.Warn("Trace level enabled: log will include sensitive information as credentials and tokens")
 	}
 }
 
diff --git a/docs/index.md b/docs/index.md
index d59b3b1..ef234ec 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,9 +17,6 @@
   <a href="https://godoc.org/github.com/containrrr/watchtower">
     <img alt="GoDoc" src="https://godoc.org/github.com/containrrr/watchtower?status.svg" />
   </a>
-  <a href="https://microbadger.com/images/containrrr/watchtower">
-    <img alt="Microbadger" src="https://images.microbadger.com/badges/image/containrrr/watchtower.svg" />
-  </a>
   <a href="https://goreportcard.com/report/github.com/containrrr/watchtower">
     <img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/containrrr/watchtower" />
   </a>
@@ -58,8 +55,8 @@ the following command:
     ```yaml
     version: "3"
     services:
-    watchtower:
-    image: containrrr/watchtower 
-    volumes:
-    - /var/run/docker.sock:/var/run/docker.sock
+      watchtower:
+        image: containrrr/watchtower
+        volumes:
+          - /var/run/docker.sock:/var/run/docker.sock
     ```
diff --git a/docs/notifications.md b/docs/notifications.md
index 3663aff..faa4e4a 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -180,7 +180,7 @@ To send notifications via shoutrrr, the following command-line options, or their
 
 -   `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
 
-Go to [containrrr.dev/shoutrrr/v0.4/services/overview](https://containrrr.dev/shoutrrr/v0.4/services/overview) to
+Go to [containrrr.dev/shoutrrr/v0.5/services/overview](https://containrrr.dev/shoutrrr/v0.5/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)
 
diff --git a/docs/private-registries.md b/docs/private-registries.md
index e6edc39..8b3f28d 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -102,8 +102,9 @@ in a volume that may be mounted onto your watchtower container.
 
 1.  Create the Dockerfile (contents below):
     ```Dockerfile
-    FROM golang:latest
+    FROM golang:1.16
     
+    ENV GO111MODULE off
     ENV CGO_ENABLED 0
     ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
     
diff --git a/go.mod b/go.mod
index 225915b..2515454 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 // indirect
 	github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect
-	github.com/containrrr/shoutrrr v0.4.4
+	github.com/containrrr/shoutrrr v0.5.1
 	github.com/docker/cli v0.0.0-20190327152802-57b27434ea29
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4
diff --git a/go.sum b/go.sum
index e1d7071..6eb935f 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,10 @@ github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882b
 github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
 github.com/containrrr/shoutrrr v0.4.4/go.mod h1:zqL2BvfC1W4FujrT4b3/ZCLxvD+uoeEpBL7rg9Dqpbg=
+github.com/containrrr/shoutrrr v0.5.0 h1:befKPRMqSvEsHYgxYJq4nuGmSWYvjbhVvb0nNk5OR5Q=
+github.com/containrrr/shoutrrr v0.5.0/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8ZrRJaEodQhbzw=
+github.com/containrrr/shoutrrr v0.5.1 h1:who87ACg0spQdbImaFMsOSh3g2FWyeN5nmO8tCg3llQ=
+github.com/containrrr/shoutrrr v0.5.1/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8ZrRJaEodQhbzw=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index e1cb5e7..7c999d0 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -30,7 +30,8 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
 
 	urls = AppendLegacyUrls(urls, c)
 
-	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, urls...)
+	title := GetTitle(c)
+	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, title, urls...)
 }
 
 // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
@@ -66,7 +67,7 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command) []string {
 
 		shoutrrrURL, err := legacyNotifier.GetURL(cmd)
 		if err != nil {
-			log.Fatal("failed to create notification config:", err)
+			log.Fatal("failed to create notification config: ", err)
 		}
 		urls = append(urls, shoutrrrURL)
 
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 41ef126..ac293fb 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -2,7 +2,6 @@ package notifications
 
 import (
 	"bytes"
-	"fmt"
 	stdlog "log"
 	"strings"
 	"text/template"
@@ -13,23 +12,33 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+// LocalLog is a logrus logger that does not send entries as notifications
+var LocalLog = log.WithField("notify", "no")
+
 const (
 	shoutrrrDefaultLegacyTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
-	shoutrrrDefaultTemplate       = `{{- with .Report -}}
+	shoutrrrDefaultTemplate       = `
+{{- if .Report -}}
+  {{- with .Report -}}
+    {{- if ( or .Updated .Failed ) -}}
 {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
-{{range .Updated -}}
+      {{- range .Updated}}
 - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
-{{end -}}
-{{range .Fresh -}}
+      {{- end -}}
+      {{- range .Fresh}}
 - {{.Name}} ({{.ImageName}}): {{.State}}
-{{end -}}
-{{range .Skipped -}}
+	  {{- end -}}
+	  {{- range .Skipped}}
 - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-{{end -}}
-{{range .Failed -}}
+	  {{- end -}}
+	  {{- range .Failed}}
 - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-{{end -}}
-{{end -}}`
+	  {{- end -}}
+    {{- end -}}
+  {{- end -}}
+{{- else -}}
+  {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}`
 	shoutrrrType = "shoutrrr"
 )
 
@@ -47,6 +56,7 @@ type shoutrrrTypeNotifier struct {
 	messages       chan string
 	done           chan bool
 	legacyTemplate bool
+	params         *types.Params
 }
 
 // GetScheme returns the scheme part of a Shoutrrr URL
@@ -58,6 +68,7 @@ func GetScheme(url string) string {
 	return url[:schemeEnd]
 }
 
+// GetNames returns a list of notification services that has been added
 func (n *shoutrrrTypeNotifier) GetNames() []string {
 	names := make([]string, len(n.Urls))
 	for i, u := range n.Urls {
@@ -66,9 +77,10 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
 	return names
 }
 
-func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, urls ...string) t.Notifier {
+func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, title string, urls ...string) t.Notifier {
 
 	notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy)
+	notifier.params = &types.Params{"title": title}
 	log.AddHook(notifier)
 
 	// Do the sending in a separate goroutine so we don't block the main process.
@@ -102,13 +114,16 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
 
 func sendNotifications(n *shoutrrrTypeNotifier) {
 	for msg := range n.messages {
-		errs := n.Router.Send(msg, nil)
+		errs := n.Router.Send(msg, n.params)
 
 		for i, err := range errs {
 			if err != nil {
 				scheme := GetScheme(n.Urls[i])
 				// Use fmt so it doesn't trigger another notification.
-				fmt.Printf("Failed to send shoutrrr notification (#%d, %s): %v\n", i, scheme, err)
+				LocalLog.WithFields(log.Fields{
+					"service": scheme,
+					"index":   i,
+				}).WithError(err).Error("Failed to send shoutrrr notification")
 			}
 		}
 	}
@@ -116,53 +131,70 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
 	n.done <- true
 }
 
-func (n *shoutrrrTypeNotifier) buildMessage(data Data) string {
+func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
 	var body bytes.Buffer
 	var templateData interface{} = data
 	if n.legacyTemplate {
 		templateData = data.Entries
 	}
 	if err := n.template.Execute(&body, templateData); err != nil {
-		fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
+		return "", err
 	}
 
-	return body.String()
+	return body.String(), nil
 }
 
 func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
-	msg := n.buildMessage(Data{entries, report})
+	msg, err := n.buildMessage(Data{entries, report})
+
+	if msg == "" {
+		// Log in go func in case we entered from Fire to avoid stalling
+		go func() {
+			if err != nil {
+				LocalLog.WithError(err).Fatal("Notification template error")
+			} else {
+				LocalLog.Info("Skipping notification due to empty message")
+			}
+		}()
+		return
+	}
 	n.messages <- msg
 }
 
+// StartNotification begins queueing up messages to send them as a batch
 func (n *shoutrrrTypeNotifier) StartNotification() {
 	if n.entries == nil {
 		n.entries = make([]*log.Entry, 0, 10)
 	}
 }
 
+// SendNotification sends the queued up messages as a notification
 func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) {
-	//if n.entries == nil || len(n.entries) <= 0 {
-	//	return
-	//}
-
 	n.sendEntries(n.entries, report)
 	n.entries = nil
 }
 
+// Close prevents further messages from being queued and waits until all the currently queued up messages have been sent
 func (n *shoutrrrTypeNotifier) Close() {
 	close(n.messages)
 
 	// Use fmt so it doesn't trigger another notification.
-	fmt.Println("Waiting for the notification goroutine to finish")
+	LocalLog.Info("Waiting for the notification goroutine to finish")
 
 	_ = <-n.done
 }
 
+// Levels return what log levels trigger notifications
 func (n *shoutrrrTypeNotifier) Levels() []log.Level {
 	return n.logLevels
 }
 
+// Fire is the hook that logrus calls on a new log message
 func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+	if entry.Data["notify"] == "no" {
+		// Skip logging if explicitly tagged as non-notify
+		return nil
+	}
 	if n.entries != nil {
 		n.entries = append(n.entries, entry)
 	} else {
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index 7f6e0d4..c5c73b2 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -49,7 +49,7 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
 		log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service")
 		conf := &shoutrrrDisco.Config{
-			Channel:    parts[len(parts)-3],
+			WebhookID:  parts[len(parts)-3],
 			Token:      parts[len(parts)-2],
 			Color:      ColorInt,
 			Title:      GetTitle(c),
@@ -59,15 +59,24 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 		return conf.GetURL().String(), nil
 	}
 
-	rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
-	tokens := strings.Split(rawTokens, "/")
+	webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
 
 	conf := &shoutrrrSlack.Config{
 		BotName: s.Username,
-		Token:   tokens,
 		Color:   ColorHex,
+		Channel: "webhook",
 		Title:   GetTitle(c),
 	}
 
+	if s.IconURL != "" {
+		conf.Icon = s.IconURL
+	} else if s.IconEmoji != "" {
+		conf.Icon = s.IconEmoji
+	}
+
+	if err := conf.Token.SetFromProp(webhookToken); err != nil {
+		return "", err
+	}
+
 	return conf.GetURL().String(), nil
 }


Test patch:
diff --git a/pkg/notifications/notifications_suite_test.go b/pkg/notifications/notifications_suite_test.go
index 1b77c2a..19d286e 100644
--- a/pkg/notifications/notifications_suite_test.go
+++ b/pkg/notifications/notifications_suite_test.go
@@ -1,6 +1,7 @@
 package notifications_test
 
 import (
+	"github.com/onsi/gomega/format"
 	"testing"
 
 	. "github.com/onsi/ginkgo"
@@ -9,5 +10,6 @@ import (
 
 func TestNotifications(t *testing.T) {
 	RegisterFailHandler(Fail)
+	format.CharactersAroundMismatchToInclude = 20
 	RunSpecs(t, "Notifications Suite")
 }
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index 58c1ebb..71bfea3 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -40,7 +40,7 @@ var _ = Describe("notifications", func() {
 			token := "abvsihdbau"
 			color := notifications.ColorInt
 			title := url.QueryEscape(notifications.GetTitle(command))
-			expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
+			expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=watchtower", token, channel, color, title)
 			buildArgs := func(url string) []string {
 				return []string{
 					"--notifications",
@@ -60,31 +60,56 @@ var _ = Describe("notifications", func() {
 			})
 		})
 		When("converting a slack service config into a shoutrrr url", func() {
+			command := cmd.NewRootCommand()
+			flags.RegisterNotificationFlags(command)
+			username := "containrrrbot"
+			tokenA := "AAAAAAAAA"
+			tokenB := "BBBBBBBBB"
+			tokenC := "123456789123456789123456"
+			color := url.QueryEscape(notifications.ColorHex)
+			title := url.QueryEscape(notifications.GetTitle(command))
+			iconURL := "https://containrrr.dev/watchtower-sq180.png"
+			iconEmoji := "whale"
+
+			When("icon URL is specified", func() {
+				It("should return the expected URL", func() {
+
+					hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+					expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL), title)
+
+					args := []string{
+						"--notifications",
+						"slack",
+						"--notification-slack-hook-url",
+						hookURL,
+						"--notification-slack-identifier",
+						username,
+						"--notification-slack-icon-url",
+						iconURL,
+					}
+
+					testURL(args, expectedOutput)
+				})
+			})
 
-			It("should return the expected URL", func() {
-				command := cmd.NewRootCommand()
-				flags.RegisterNotificationFlags(command)
-
-				username := "containrrrbot"
-				tokenA := "aaa"
-				tokenB := "bbb"
-				tokenC := "ccc"
-				color := url.QueryEscape(notifications.ColorHex)
-				title := url.QueryEscape(notifications.GetTitle(command))
-
-				hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
-				expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
-
-				args := []string{
-					"--notifications",
-					"slack",
-					"--notification-slack-hook-url",
-					hookURL,
-					"--notification-slack-identifier",
-					username,
-				}
-
-				testURL(args, expectedOutput)
+			When("icon emoji is specified", func() {
+				It("should return the expected URL", func() {
+					hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+					expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, iconEmoji, title)
+
+					args := []string{
+						"--notifications",
+						"slack",
+						"--notification-slack-hook-url",
+						hookURL,
+						"--notification-slack-identifier",
+						username,
+						"--notification-slack-icon-emoji",
+						iconEmoji,
+					}
+
+					testURL(args, expectedOutput)
+				})
 			})
 		})
 	})
@@ -208,6 +233,7 @@ func buildExpectedURL(username string, password string, host string, port int, f
 }
 
 func testURL(args []string, expectedURL string) {
+	defer GinkgoRecover()
 
 	command := cmd.NewRootCommand()
 	flags.RegisterNotificationFlags(command)
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index e92655c..1b4ebab 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -8,11 +8,12 @@ import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 	"github.com/onsi/gomega/gbytes"
-
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
 
+var allButTrace = logrus.AllLevels[0:logrus.TraceLevel]
+
 var legacyMockData = Data{
 	Entries: []*logrus.Entry{
 		{
@@ -22,6 +23,28 @@ var legacyMockData = Data{
 	},
 }
 
+var mockDataMultipleEntries = Data{
+	Entries: []*logrus.Entry{
+		{
+			Level:   logrus.InfoLevel,
+			Message: "The situation is under control",
+		},
+		{
+			Level:   logrus.WarnLevel,
+			Message: "All the smoke might be covering up some problems",
+		},
+		{
+			Level:   logrus.ErrorLevel,
+			Message: "Turns out everything is on fire",
+		},
+	},
+}
+
+var mockDataAllFresh = Data{
+	Entries: []*logrus.Entry{},
+	Report:  mocks.CreateMockProgressReport(s.FreshState),
+}
+
 func mockDataFromStates(states ...s.State) Data {
 	return Data{
 		Entries: legacyMockData.Entries,
@@ -35,6 +58,7 @@ var _ = Describe("Shoutrrr", func() {
 	BeforeEach(func() {
 		logBuffer = gbytes.NewBuffer()
 		logrus.SetOutput(logBuffer)
+		logrus.SetLevel(logrus.TraceLevel)
 		logrus.SetFormatter(&logrus.TextFormatter{
 			DisableColors:    true,
 			DisableTimestamp: true,
@@ -56,7 +80,8 @@ var _ = Describe("Shoutrrr", func() {
 					},
 				}
 
-				s := shoutrrr.buildMessage(Data{Entries: entries})
+				s, err := shoutrrr.buildMessage(Data{Entries: entries})
+				Expect(err).NotTo(HaveOccurred())
 
 				Expect(s).To(Equal("foo bar\n"))
 			})
@@ -80,21 +105,34 @@ var _ = Describe("Shoutrrr", func() {
 					},
 				}
 
-				s := shoutrrr.buildMessage(Data{Entries: entries})
+				s, err := shoutrrr.buildMessage(Data{Entries: entries})
+				Expect(err).NotTo(HaveOccurred())
 
 				Expect(s).To(Equal("info: foo bar\n"))
 			})
 		})
 
+		Describe("the default template", func() {
+			When("all containers are fresh", func() {
+				It("should return an empty string", func() {
+					Expect(getTemplatedResult(``, true, mockDataAllFresh)).To(Equal(""))
+				})
+			})
+		})
+
 		When("given an invalid custom template", func() {
 			It("should format the messages using the default template", func() {
 				invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true)
 				Expect(err).To(HaveOccurred())
+				invMsg, err := invNotif.buildMessage(legacyMockData)
+				Expect(err).NotTo(HaveOccurred())
 
 				defNotif, err := createNotifierWithTemplate(``, true)
 				Expect(err).ToNot(HaveOccurred())
+				defMsg, err := defNotif.buildMessage(legacyMockData)
+				Expect(err).ToNot(HaveOccurred())
 
-				Expect(invNotif.buildMessage(legacyMockData)).To(Equal(defNotif.buildMessage(legacyMockData)))
+				Expect(invMsg).To(Equal(defMsg))
 			})
 		})
 
@@ -130,18 +168,63 @@ var _ = Describe("Shoutrrr", func() {
 - updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000
 - frsh1 (mock/frsh1:latest): Fresh
 - skip1 (mock/skip1:latest): Skipped: unpossible
-- fail1 (mock/fail1:latest): Failed: accidentally the whole container
-`
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
 				data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
 				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
 			})
 
-			It("should format the messages using the default template", func() {
-				expected := `1 Scanned, 0 Updated, 0 Failed
-- frsh1 (mock/frsh1:latest): Fresh
+		})
+
+		Describe("the default template", func() {
+			When("all containers are fresh", func() {
+				It("should return an empty string", func() {
+					Expect(getTemplatedResult(``, false, mockDataAllFresh)).To(Equal(""))
+				})
+			})
+			When("at least one container was updated", func() {
+				It("should send a report", func() {
+					expected := `1 Scanned, 1 Updated, 0 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000`
+					data := mockDataFromStates(s.UpdatedState)
+					Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+				})
+			})
+			When("at least one container failed to update", func() {
+				It("should send a report", func() {
+					expected := `1 Scanned, 0 Updated, 1 Failed
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+					data := mockDataFromStates(s.FailedState)
+					Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+				})
+			})
+			When("the report is nil", func() {
+				It("should return the logged entries", func() {
+					expected := `The situation is under control
+All the smoke might be covering up some problems
+Turns out everything is on fire
 `
-				data := mockDataFromStates(s.FreshState)
-				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+					Expect(getTemplatedResult(``, false, mockDataMultipleEntries)).To(Equal(expected))
+				})
+			})
+		})
+	})
+
+	When("batching notifications", func() {
+		When("no messages are queued", func() {
+			It("should not send any notification", func() {
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", "logger://")
+				shoutrrr.StartNotification()
+				shoutrrr.SendNotification(nil)
+				Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
+			})
+		})
+		When("at least one message is queued", func() {
+			It("should send a notification", func() {
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", "logger://")
+				shoutrrr.StartNotification()
+				logrus.Info("This log message is sponsored by ContainrrrVPN")
+				shoutrrr.SendNotification(nil)
+				Eventually(logBuffer).Should(gbytes.Say(`Shoutrrr: This log message is sponsored by ContainrrrVPN`))
 			})
 		})
 	})
@@ -218,10 +301,10 @@ func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNot
 	}, err
 }
 
-func getTemplatedResult(tplString string, legacy bool, data Data) (string, error) {
+func getTemplatedResult(tplString string, legacy bool, data Data) (msg string) {
 	notifier, err := createNotifierWithTemplate(tplString, legacy)
-	if err != nil {
-		return "", err
-	}
-	return notifier.buildMessage(data), err
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+	msg, err = notifier.buildMessage(data)
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+	return msg
 }