You've already forked watchtower
							
							
				mirror of
				https://github.com/containrrr/watchtower.git
				synced 2025-10-31 00:17:44 +02:00 
			
		
		
		
	
							
								
								
									
										23
									
								
								cmd/root.go
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								cmd/root.go
									
									
									
									
									
								
							| @@ -34,15 +34,20 @@ var ( | |||||||
| 	scope          string | 	scope          string | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var rootCmd = &cobra.Command{ | var rootCmd = NewRootCommand() | ||||||
| 	Use:   "watchtower", |  | ||||||
| 	Short: "Automatically updates running Docker containers", | // NewRootCommand creates the root command for watchtower | ||||||
| 	Long: ` | func NewRootCommand() *cobra.Command { | ||||||
| Watchtower automatically updates running Docker containers whenever a new image is released. | 	return &cobra.Command{ | ||||||
| More information available at https://github.com/containrrr/watchtower/. | 		Use:   "watchtower", | ||||||
| `, | 		Short: "Automatically updates running Docker containers", | ||||||
| 	Run:    Run, | 		Long: ` | ||||||
| 	PreRun: PreRun, | 	Watchtower automatically updates running Docker containers whenever a new image is released. | ||||||
|  | 	More information available at https://github.com/containrrr/watchtower/. | ||||||
|  | 	`, | ||||||
|  | 		Run:    Run, | ||||||
|  | 		PreRun: PreRun, | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| func init() { | func init() { | ||||||
|   | |||||||
| @@ -1,29 +1,22 @@ | |||||||
| package notifications | package notifications | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/base64" |  | ||||||
| 	"fmt" |  | ||||||
| 	"github.com/spf13/cobra" |  | ||||||
| 	"net/smtp" |  | ||||||
| 	"os" | 	"os" | ||||||
| 	"strings" |  | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/spf13/cobra" | ||||||
|  |  | ||||||
|  | 	shoutrrrSmtp "github.com/containrrr/shoutrrr/pkg/services/smtp" | ||||||
| 	t "github.com/containrrr/watchtower/pkg/types" | 	t "github.com/containrrr/watchtower/pkg/types" | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
| 	"strconv" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| 	emailType = "email" | 	emailType = "email" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Implements Notifier, logrus.Hook |  | ||||||
| // The default logrus email integration would have several issues: |  | ||||||
| // - It would send one email per log output |  | ||||||
| // - It would only send errors |  | ||||||
| // We work around that by holding on to log entries until the update cycle is done. |  | ||||||
| type emailTypeNotifier struct { | type emailTypeNotifier struct { | ||||||
|  | 	url                                string | ||||||
| 	From, To                           string | 	From, To                           string | ||||||
| 	Server, User, Password, SubjectTag string | 	Server, User, Password, SubjectTag string | ||||||
| 	Port                               int | 	Port                               int | ||||||
| @@ -33,7 +26,12 @@ type emailTypeNotifier struct { | |||||||
| 	delay                              time.Duration | 	delay                              time.Duration | ||||||
| } | } | ||||||
|  |  | ||||||
| func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | // NewEmailNotifier is a factory method creating a new email notifier instance | ||||||
|  | func NewEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
|  | 	return newEmailNotifier(c, acceptedLogLevels) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
| 	flags := c.PersistentFlags() | 	flags := c.PersistentFlags() | ||||||
|  |  | ||||||
| 	from, _ := flags.GetString("notification-email-from") | 	from, _ := flags.GetString("notification-email-from") | ||||||
| @@ -47,6 +45,7 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie | |||||||
| 	subjecttag, _ := flags.GetString("notification-email-subjecttag") | 	subjecttag, _ := flags.GetString("notification-email-subjecttag") | ||||||
|  |  | ||||||
| 	n := &emailTypeNotifier{ | 	n := &emailTypeNotifier{ | ||||||
|  | 		entries:       []*log.Entry{}, | ||||||
| 		From:          from, | 		From:          from, | ||||||
| 		To:            to, | 		To:            to, | ||||||
| 		Server:        server, | 		Server:        server, | ||||||
| @@ -59,12 +58,33 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie | |||||||
| 		SubjectTag:    subjecttag, | 		SubjectTag:    subjecttag, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.AddHook(n) |  | ||||||
|  |  | ||||||
| 	return n | 	return n | ||||||
| } | } | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte { | func (e *emailTypeNotifier) GetURL() string { | ||||||
|  | 	conf := &shoutrrrSmtp.Config{ | ||||||
|  | 		FromAddress: e.From, | ||||||
|  | 		FromName:    "Watchtower", | ||||||
|  | 		ToAddresses: []string{e.To}, | ||||||
|  | 		Port:        uint16(e.Port), | ||||||
|  | 		Host:        e.Server, | ||||||
|  | 		Subject:     e.getSubject(), | ||||||
|  | 		Username:    e.User, | ||||||
|  | 		Password:    e.Password, | ||||||
|  | 		UseStartTLS: true, | ||||||
|  | 		UseHTML:     false, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(e.User) > 0 { | ||||||
|  | 		conf.Set("auth", "Plain") | ||||||
|  | 	} else { | ||||||
|  | 		conf.Set("auth", "None") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return conf.GetURL().String() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (e *emailTypeNotifier) getSubject() string { | ||||||
| 	var emailSubject string | 	var emailSubject string | ||||||
|  |  | ||||||
| 	if e.SubjectTag == "" { | 	if e.SubjectTag == "" { | ||||||
| @@ -75,83 +95,13 @@ func (e *emailTypeNotifier) buildMessage(entries []*log.Entry) []byte { | |||||||
| 	if hostname, err := os.Hostname(); err == nil { | 	if hostname, err := os.Hostname(); err == nil { | ||||||
| 		emailSubject += " on " + hostname | 		emailSubject += " on " + hostname | ||||||
| 	} | 	} | ||||||
| 	body := "" | 	return emailSubject | ||||||
| 	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. |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	t := time.Now() |  | ||||||
|  |  | ||||||
| 	header := make(map[string]string) |  | ||||||
| 	header["From"] = e.From |  | ||||||
| 	header["To"] = e.To |  | ||||||
| 	header["Subject"] = emailSubject |  | ||||||
| 	header["Date"] = t.Format(time.RFC1123Z) |  | ||||||
| 	header["MIME-Version"] = "1.0" |  | ||||||
| 	header["Content-Type"] = "text/plain; charset=\"utf-8\"" |  | ||||||
| 	header["Content-Transfer-Encoding"] = "base64" |  | ||||||
|  |  | ||||||
| 	message := "" |  | ||||||
| 	for k, v := range header { |  | ||||||
| 		message += fmt.Sprintf("%s: %s\r\n", k, v) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	encodedBody := base64.StdEncoding.EncodeToString([]byte(body)) |  | ||||||
| 	//RFC 2045 base64 encoding demands line no longer than 76 characters. |  | ||||||
| 	for _, line := range SplitSubN(encodedBody, 76) { |  | ||||||
| 		message += "\r\n" + line |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return []byte(message) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) sendEntries(entries []*log.Entry) { | // TODO: Delete these once all notifiers have been converted to shoutrrr | ||||||
| 	// Do the sending in a separate goroutine so we don't block the main process. | func (e *emailTypeNotifier) StartNotification()          {} | ||||||
| 	msg := e.buildMessage(entries) | func (e *emailTypeNotifier) SendNotification()           {} | ||||||
| 	go func() { | func (e *emailTypeNotifier) Levels() []log.Level         { return nil } | ||||||
| 		if e.delay > 0 { | func (e *emailTypeNotifier) Fire(entry *log.Entry) error { return nil } | ||||||
| 			time.Sleep(e.delay) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var auth smtp.Auth |  | ||||||
| 		if e.User != "" { |  | ||||||
| 			auth = smtp.PlainAuth("", e.User, e.Password, e.Server) |  | ||||||
| 		} |  | ||||||
| 		err := SendMail(e.Server+":"+strconv.Itoa(e.Port), e.tlsSkipVerify, auth, e.From, strings.Split(e.To, ","), msg) |  | ||||||
| 		if err != nil { |  | ||||||
| 			// Use fmt so it doesn't trigger another email. |  | ||||||
| 			fmt.Println("Failed to send notification email: ", err) |  | ||||||
| 		} |  | ||||||
| 	}() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) StartNotification() { |  | ||||||
| 	if e.entries == nil { |  | ||||||
| 		e.entries = make([]*log.Entry, 0, 10) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) SendNotification() { |  | ||||||
| 	if e.entries == nil || len(e.entries) <= 0 { |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	e.sendEntries(e.entries) |  | ||||||
| 	e.entries = nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) Levels() []log.Level { |  | ||||||
| 	return e.logLevels |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) Fire(entry *log.Entry) error { |  | ||||||
| 	if e.entries != nil { |  | ||||||
| 		e.entries = append(e.entries, entry) |  | ||||||
| 	} else { |  | ||||||
| 		e.sendEntries([]*log.Entry{entry}) |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (e *emailTypeNotifier) Close() {} | func (e *emailTypeNotifier) Close() {} | ||||||
|   | |||||||
| @@ -1,16 +1,13 @@ | |||||||
| package notifications | package notifications | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"bytes" |  | ||||||
| 	"crypto/tls" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"net/http" |  | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
|  | 	shoutrrrGotify "github.com/containrrr/shoutrrr/pkg/services/gotify" | ||||||
| 	t "github.com/containrrr/watchtower/pkg/types" | 	t "github.com/containrrr/watchtower/pkg/types" | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
| 	"github.com/spf13/cobra" | 	"github.com/spf13/cobra" | ||||||
|  | 	"github.com/spf13/pflag" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| @@ -24,10 +21,40 @@ type gotifyTypeNotifier struct { | |||||||
| 	logLevels                []log.Level | 	logLevels                []log.Level | ||||||
| } | } | ||||||
|  |  | ||||||
| func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | // NewGotifyNotifier is a factory method creating a new gotify notifier instance | ||||||
|  | func NewGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertableNotifier { | ||||||
|  | 	return newGotifyNotifier(c, levels) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertableNotifier { | ||||||
| 	flags := c.PersistentFlags() | 	flags := c.PersistentFlags() | ||||||
|  |  | ||||||
|  | 	url := getGotifyURL(flags) | ||||||
|  | 	token := getGotifyToken(flags) | ||||||
|  |  | ||||||
|  | 	skipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify") | ||||||
|  |  | ||||||
|  | 	n := &gotifyTypeNotifier{ | ||||||
|  | 		gotifyURL:                url, | ||||||
|  | 		gotifyAppToken:           token, | ||||||
|  | 		gotifyInsecureSkipVerify: skipVerify, | ||||||
|  | 		logLevels:                levels, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return n | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getGotifyToken(flags *pflag.FlagSet) string { | ||||||
|  | 	gotifyToken, _ := flags.GetString("notification-gotify-token") | ||||||
|  | 	if len(gotifyToken) < 1 { | ||||||
|  | 		log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.") | ||||||
|  | 	} | ||||||
|  | 	return gotifyToken | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getGotifyURL(flags *pflag.FlagSet) string { | ||||||
| 	gotifyURL, _ := flags.GetString("notification-gotify-url") | 	gotifyURL, _ := flags.GetString("notification-gotify-url") | ||||||
|  |  | ||||||
| 	if len(gotifyURL) < 1 { | 	if len(gotifyURL) < 1 { | ||||||
| 		log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.") | 		log.Fatal("Required argument --notification-gotify-url(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_URL(env) is empty.") | ||||||
| 	} else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) { | 	} else if !(strings.HasPrefix(gotifyURL, "http://") || strings.HasPrefix(gotifyURL, "https://")) { | ||||||
| @@ -36,82 +63,29 @@ func newGotifyNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifi | |||||||
| 		log.Warn("Using an HTTP url for Gotify is insecure") | 		log.Warn("Using an HTTP url for Gotify is insecure") | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	gotifyToken, _ := flags.GetString("notification-gotify-token") | 	return gotifyURL | ||||||
| 	if len(gotifyToken) < 1 { |  | ||||||
| 		log.Fatal("Required argument --notification-gotify-token(cli) or WATCHTOWER_NOTIFICATION_GOTIFY_TOKEN(env) is empty.") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	gotifyInsecureSkipVerify, _ := flags.GetBool("notification-gotify-tls-skip-verify") |  | ||||||
|  |  | ||||||
| 	n := &gotifyTypeNotifier{ |  | ||||||
| 		gotifyURL:                gotifyURL, |  | ||||||
| 		gotifyAppToken:           gotifyToken, |  | ||||||
| 		gotifyInsecureSkipVerify: gotifyInsecureSkipVerify, |  | ||||||
| 		logLevels:                acceptedLogLevels, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	log.AddHook(n) |  | ||||||
|  |  | ||||||
| 	return n |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) StartNotification() {} | func (n *gotifyTypeNotifier) GetURL() string { | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) SendNotification() {} |  | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) Close() {} |  | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) Levels() []log.Level { |  | ||||||
| 	return n.logLevels |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) getURL() string { |  | ||||||
| 	url := n.gotifyURL | 	url := n.gotifyURL | ||||||
| 	if !strings.HasSuffix(url, "/") { |  | ||||||
| 		url += "/" | 	if strings.HasPrefix(url, "https://") { | ||||||
|  | 		url = strings.TrimPrefix(url, "https://") | ||||||
|  | 	} else { | ||||||
|  | 		url = strings.TrimPrefix(url, "http://") | ||||||
| 	} | 	} | ||||||
| 	return url + "message?token=" + n.gotifyAppToken |  | ||||||
|  | 	url = strings.TrimSuffix(url, "/") | ||||||
|  |  | ||||||
|  | 	config := &shoutrrrGotify.Config{ | ||||||
|  | 		Host:  url, | ||||||
|  | 		Token: n.gotifyAppToken, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return config.GetURL().String() | ||||||
| } | } | ||||||
|  |  | ||||||
| func (n *gotifyTypeNotifier) Fire(entry *log.Entry) error { | func (n *gotifyTypeNotifier) StartNotification()  {} | ||||||
|  | func (n *gotifyTypeNotifier) SendNotification()   {} | ||||||
| 	go func() { | func (n *gotifyTypeNotifier) Close()              {} | ||||||
| 		jsonBody, err := json.Marshal(gotifyMessage{ | func (n *gotifyTypeNotifier) Levels() []log.Level { return nil } | ||||||
| 			Message:  "(" + entry.Level.String() + "): " + entry.Message, |  | ||||||
| 			Title:    "Watchtower", |  | ||||||
| 			Priority: 0, |  | ||||||
| 		}) |  | ||||||
| 		if err != nil { |  | ||||||
| 			fmt.Println("Failed to create JSON body for Gotify notification: ", err) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Explicitly define the client so we can set InsecureSkipVerify to the desired value. |  | ||||||
| 		client := &http.Client{ |  | ||||||
| 			Transport: &http.Transport{ |  | ||||||
| 				TLSClientConfig: &tls.Config{ |  | ||||||
| 					InsecureSkipVerify: n.gotifyInsecureSkipVerify, |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 		} |  | ||||||
| 		jsonBodyBuffer := bytes.NewBuffer([]byte(jsonBody)) |  | ||||||
| 		resp, err := client.Post(n.getURL(), "application/json", jsonBodyBuffer) |  | ||||||
| 		if err != nil { |  | ||||||
| 			fmt.Println("Failed to send Gotify notification: ", err) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		defer resp.Body.Close() |  | ||||||
|  |  | ||||||
| 		if resp.StatusCode < 200 || resp.StatusCode >= 300 { |  | ||||||
| 			fmt.Printf("Gotify notification returned %d HTTP status code", resp.StatusCode) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 	}() |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type gotifyMessage struct { |  | ||||||
| 	Message  string `json:"message"` |  | ||||||
| 	Title    string `json:"title"` |  | ||||||
| 	Priority int    `json:"priority"` |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -1,15 +1,12 @@ | |||||||
| package notifications | package notifications | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"strings" | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"github.com/spf13/cobra" |  | ||||||
| 	"net/http" |  | ||||||
|  |  | ||||||
|  | 	shoutrrrTeams "github.com/containrrr/shoutrrr/pkg/services/teams" | ||||||
| 	t "github.com/containrrr/watchtower/pkg/types" | 	t "github.com/containrrr/watchtower/pkg/types" | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
| 	"io/ioutil" | 	"github.com/spf13/cobra" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
| @@ -22,7 +19,12 @@ type msTeamsTypeNotifier struct { | |||||||
| 	data       bool | 	data       bool | ||||||
| } | } | ||||||
|  |  | ||||||
| func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | // NewMsTeamsNotifier is a factory method creating a new teams notifier instance | ||||||
|  | func NewMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
|  | 	return newMsTeamsNotifier(cmd, acceptedLogLevels) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
|  |  | ||||||
| 	flags := cmd.PersistentFlags() | 	flags := cmd.PersistentFlags() | ||||||
|  |  | ||||||
| @@ -38,103 +40,29 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Not | |||||||
| 		data:       withData, | 		data:       withData, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.AddHook(n) |  | ||||||
|  |  | ||||||
| 	return n | 	return n | ||||||
| } | } | ||||||
|  |  | ||||||
| func (n *msTeamsTypeNotifier) StartNotification() {} | func (n *msTeamsTypeNotifier) GetURL() string { | ||||||
|  |  | ||||||
| func (n *msTeamsTypeNotifier) SendNotification() {} | 	baseURL := "https://outlook.office.com/webhook/" | ||||||
|  |  | ||||||
| func (n *msTeamsTypeNotifier) Close() {} | 	path := strings.Replace(n.webHookURL, baseURL, "", 1) | ||||||
|  | 	rawToken := strings.Replace(path, "/IncomingWebhook", "", 1) | ||||||
|  | 	token := strings.Split(rawToken, "/") | ||||||
|  | 	config := &shoutrrrTeams.Config{ | ||||||
|  | 		Token: shoutrrrTeams.Token{ | ||||||
|  | 			A: token[0], | ||||||
|  | 			B: token[1], | ||||||
|  | 			C: token[2], | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  |  | ||||||
| func (n *msTeamsTypeNotifier) Levels() []log.Level { | 	return config.GetURL().String() | ||||||
| 	return n.levels |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error { | func (n *msTeamsTypeNotifier) StartNotification()          {} | ||||||
|  | func (n *msTeamsTypeNotifier) SendNotification()           {} | ||||||
| 	message := "(" + entry.Level.String() + "): " + entry.Message | func (n *msTeamsTypeNotifier) Close()                      {} | ||||||
|  | func (n *msTeamsTypeNotifier) Levels() []log.Level         { return nil } | ||||||
| 	go func() { | func (n *msTeamsTypeNotifier) Fire(entry *log.Entry) error { return nil } | ||||||
| 		webHookBody := messageCard{ |  | ||||||
| 			CardType: "MessageCard", |  | ||||||
| 			Context:  "http://schema.org/extensions", |  | ||||||
| 			Markdown: true, |  | ||||||
| 			Text:     message, |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if n.data && entry.Data != nil && len(entry.Data) > 0 { |  | ||||||
| 			section := messageCardSection{ |  | ||||||
| 				Facts: make([]messageCardSectionFact, len(entry.Data)), |  | ||||||
| 				Text:  "", |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			index := 0 |  | ||||||
| 			for k, v := range entry.Data { |  | ||||||
| 				section.Facts[index] = messageCardSectionFact{ |  | ||||||
| 					Name:  k, |  | ||||||
| 					Value: fmt.Sprint(v), |  | ||||||
| 				} |  | ||||||
| 				index++ |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			webHookBody.Sections = []messageCardSection{section} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		jsonBody, err := json.Marshal(webHookBody) |  | ||||||
| 		if err != nil { |  | ||||||
| 			fmt.Println("Failed to build JSON body for MSTeams notificattion: ", err) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		resp, err := http.Post(n.webHookURL, "application/json", bytes.NewBuffer([]byte(jsonBody))) |  | ||||||
| 		if err != nil { |  | ||||||
| 			fmt.Println("Failed to send MSTeams notificattion: ", err) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		defer resp.Body.Close() |  | ||||||
|  |  | ||||||
| 		if resp.StatusCode < 200 || resp.StatusCode > 299 { |  | ||||||
| 			fmt.Println("Failed to send MSTeams notificattion. HTTP RESPONSE STATUS: ", resp.StatusCode) |  | ||||||
| 			if resp.Body != nil { |  | ||||||
| 				bodyBytes, err := ioutil.ReadAll(resp.Body) |  | ||||||
| 				if err == nil { |  | ||||||
| 					bodyString := string(bodyBytes) |  | ||||||
| 					fmt.Println(bodyString) |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	}() |  | ||||||
|  |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type messageCard struct { |  | ||||||
| 	CardType      string               `json:"@type"` |  | ||||||
| 	Context       string               `json:"@context"` |  | ||||||
| 	CorrelationID string               `json:"correlationId,omitempty"` |  | ||||||
| 	ThemeColor    string               `json:"themeColor,omitempty"` |  | ||||||
| 	Summary       string               `json:"summary,omitempty"` |  | ||||||
| 	Title         string               `json:"title,omitempty"` |  | ||||||
| 	Text          string               `json:"text,omitempty"` |  | ||||||
| 	Markdown      bool                 `json:"markdown,bool"` |  | ||||||
| 	Sections      []messageCardSection `json:"sections,omitempty"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type messageCardSection struct { |  | ||||||
| 	Title            string                   `json:"title,omitempty"` |  | ||||||
| 	Text             string                   `json:"text,omitempty"` |  | ||||||
| 	ActivityTitle    string                   `json:"activityTitle,omitempty"` |  | ||||||
| 	ActivitySubtitle string                   `json:"activitySubtitle,omitempty"` |  | ||||||
| 	ActivityImage    string                   `json:"activityImage,omitempty"` |  | ||||||
| 	ActivityText     string                   `json:"activityText,omitempty"` |  | ||||||
| 	HeroImage        string                   `json:"heroImage,omitempty"` |  | ||||||
| 	Facts            []messageCardSectionFact `json:"facts,omitempty"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type messageCardSectionFact struct { |  | ||||||
| 	Name  string `json:"name,omitempty"` |  | ||||||
| 	Value string `json:"value,omitempty"` |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -31,26 +31,48 @@ func NewNotifier(c *cobra.Command) *Notifier { | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.WithField("could not read notifications argument", log.Fields{"Error": err}).Fatal() | 		log.WithField("could not read notifications argument", log.Fields{"Error": err}).Fatal() | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	n.types = n.GetNotificationTypes(c, acceptedLogLevels, types) | ||||||
|  |  | ||||||
|  | 	return n | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetNotificationTypes produces an array of notifiers from a list of types | ||||||
|  | func (n *Notifier) GetNotificationTypes(cmd *cobra.Command, levels []log.Level, types []string) []ty.Notifier { | ||||||
|  | 	output := make([]ty.Notifier, 0) | ||||||
|  |  | ||||||
| 	for _, t := range types { | 	for _, t := range types { | ||||||
| 		var tn ty.Notifier |  | ||||||
|  | 		if t == shoutrrrType { | ||||||
|  | 			output = append(output, newShoutrrrNotifier(cmd, levels)) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		var legacyNotifier ty.ConvertableNotifier | ||||||
|  |  | ||||||
| 		switch t { | 		switch t { | ||||||
| 		case emailType: | 		case emailType: | ||||||
| 			tn = newEmailNotifier(c, acceptedLogLevels) | 			legacyNotifier = newEmailNotifier(cmd, []log.Level{}) | ||||||
| 		case slackType: | 		case slackType: | ||||||
| 			tn = newSlackNotifier(c, acceptedLogLevels) | 			legacyNotifier = newSlackNotifier(cmd, []log.Level{}) | ||||||
| 		case msTeamsType: | 		case msTeamsType: | ||||||
| 			tn = newMsTeamsNotifier(c, acceptedLogLevels) | 			legacyNotifier = newMsTeamsNotifier(cmd, levels) | ||||||
| 		case gotifyType: | 		case gotifyType: | ||||||
| 			tn = newGotifyNotifier(c, acceptedLogLevels) | 			legacyNotifier = newGotifyNotifier(cmd, []log.Level{}) | ||||||
| 		case shoutrrrType: |  | ||||||
| 			tn = newShoutrrrNotifier(c, acceptedLogLevels) |  | ||||||
| 		default: | 		default: | ||||||
| 			log.Fatalf("Unknown notification type %q", t) | 			log.Fatalf("Unknown notification type %q", t) | ||||||
| 		} | 		} | ||||||
| 		n.types = append(n.types, tn) |  | ||||||
|  | 		notifier := newShoutrrrNotifierFromURL( | ||||||
|  | 			cmd, | ||||||
|  | 			legacyNotifier.GetURL(), | ||||||
|  | 			levels, | ||||||
|  | 		) | ||||||
|  |  | ||||||
|  | 		output = append(output, notifier) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return n | 	return output | ||||||
| } | } | ||||||
|  |  | ||||||
| // StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called. | // StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called. | ||||||
|   | |||||||
							
								
								
									
										163
									
								
								pkg/notifications/notifier_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								pkg/notifications/notifier_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | |||||||
|  | package notifications_test | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"os" | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"github.com/containrrr/watchtower/cmd" | ||||||
|  | 	"github.com/containrrr/watchtower/internal/flags" | ||||||
|  | 	"github.com/containrrr/watchtower/pkg/notifications" | ||||||
|  | 	"github.com/containrrr/watchtower/pkg/types" | ||||||
|  |  | ||||||
|  | 	. "github.com/onsi/ginkgo" | ||||||
|  | 	. "github.com/onsi/gomega" | ||||||
|  | 	log "github.com/sirupsen/logrus" | ||||||
|  | 	"github.com/spf13/cobra" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestActions(t *testing.T) { | ||||||
|  | 	RegisterFailHandler(Fail) | ||||||
|  | 	RunSpecs(t, "Notifier Suite") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var _ = Describe("notifications", func() { | ||||||
|  | 	// TODO: Either, we delete this test or we need to pass it valid URLs in the cobra command. | ||||||
|  | 	// --- | ||||||
|  | 	// When("getting notifiers from a types array", func() { | ||||||
|  | 	// 	It("should return the same amount of notifiers a string entries", func() { | ||||||
|  |  | ||||||
|  | 	// 		notifier := ¬ifications.Notifier{} | ||||||
|  | 	// 		notifiers := notifier.GetNotificationTypes(&cobra.Command{}, []log.Level{}, []string{"slack", "email"}) | ||||||
|  | 	// 		Expect(len(notifiers)).To(Equal(2)) | ||||||
|  | 	// 	}) | ||||||
|  | 	// }) | ||||||
|  | 	Describe("the slack notifier", func() { | ||||||
|  | 		When("converting a slack service config into a shoutrrr url", func() { | ||||||
|  | 			builderFn := notifications.NewSlackNotifier | ||||||
|  |  | ||||||
|  | 			It("should return the expected URL", func() { | ||||||
|  |  | ||||||
|  | 				username := "containrrrbot" | ||||||
|  | 				tokenA := "aaa" | ||||||
|  | 				tokenB := "bbb" | ||||||
|  | 				tokenC := "ccc" | ||||||
|  |  | ||||||
|  | 				password := fmt.Sprintf("%s-%s-%s", tokenA, tokenB, tokenC) | ||||||
|  | 				hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC) | ||||||
|  | 				expectedOutput := fmt.Sprintf("slack://%s:%s@%s/%s/%s", username, password, tokenA, tokenB, tokenC) | ||||||
|  |  | ||||||
|  | 				args := []string{ | ||||||
|  | 					"--notification-slack-hook-url", | ||||||
|  | 					hookURL, | ||||||
|  | 					"--notification-slack-identifier", | ||||||
|  | 					username, | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				testURL(builderFn, args, expectedOutput) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	Describe("the gotify notifier", func() { | ||||||
|  | 		When("converting a gotify service config into a shoutrrr url", func() { | ||||||
|  | 			builderFn := notifications.NewGotifyNotifier | ||||||
|  |  | ||||||
|  | 			It("should return the expected URL", func() { | ||||||
|  | 				token := "aaa" | ||||||
|  | 				host := "shoutrrr.local" | ||||||
|  |  | ||||||
|  | 				expectedOutput := fmt.Sprintf("gotify://%s/%s", host, token) | ||||||
|  |  | ||||||
|  | 				args := []string{ | ||||||
|  | 					"--notification-gotify-url", | ||||||
|  | 					fmt.Sprintf("https://%s", host), | ||||||
|  | 					"--notification-gotify-token", | ||||||
|  | 					token, | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				testURL(builderFn, args, expectedOutput) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	Describe("the teams notifier", func() { | ||||||
|  | 		When("converting a teams service config into a shoutrrr url", func() { | ||||||
|  | 			builderFn := notifications.NewMsTeamsNotifier | ||||||
|  |  | ||||||
|  | 			It("should return the expected URL", func() { | ||||||
|  |  | ||||||
|  | 				tokenA := "aaa" | ||||||
|  | 				tokenB := "bbb" | ||||||
|  | 				tokenC := "ccc" | ||||||
|  |  | ||||||
|  | 				hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC) | ||||||
|  | 				expectedOutput := fmt.Sprintf("teams://%s:%s@%s", tokenA, tokenB, tokenC) | ||||||
|  |  | ||||||
|  | 				args := []string{ | ||||||
|  | 					"--notification-msteams-hook", | ||||||
|  | 					hookURL, | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				testURL(builderFn, args, expectedOutput) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	Describe("the email notifier", func() { | ||||||
|  |  | ||||||
|  | 		builderFn := notifications.NewEmailNotifier | ||||||
|  |  | ||||||
|  | 		When("converting an email service config into a shoutrrr url", func() { | ||||||
|  | 			It("should set the from address in the URL", func() { | ||||||
|  | 				fromAddress := "lala@example.com" | ||||||
|  | 				expectedOutput := buildExpectedURL("", "", "", 25, fromAddress, "", "None") | ||||||
|  | 				args := []string{ | ||||||
|  | 					"--notification-email-from", | ||||||
|  | 					fromAddress, | ||||||
|  | 				} | ||||||
|  | 				testURL(builderFn, args, expectedOutput) | ||||||
|  | 			}) | ||||||
|  |  | ||||||
|  | 			It("should return the expected URL", func() { | ||||||
|  |  | ||||||
|  | 				fromAddress := "sender@example.com" | ||||||
|  | 				toAddress := "receiver@example.com" | ||||||
|  | 				expectedOutput := buildExpectedURL("", "", "", 25, fromAddress, toAddress, "None") | ||||||
|  |  | ||||||
|  | 				args := []string{ | ||||||
|  | 					"--notification-email-from", | ||||||
|  | 					fromAddress, | ||||||
|  | 					"--notification-email-to", | ||||||
|  | 					toAddress, | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				testURL(builderFn, args, expectedOutput) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | func buildExpectedURL(username string, password string, host string, port int, from string, to string, auth string) string { | ||||||
|  | 	hostname, err := os.Hostname() | ||||||
|  | 	Expect(err).NotTo(HaveOccurred()) | ||||||
|  |  | ||||||
|  | 	subject := fmt.Sprintf("Watchtower updates on %s", hostname) | ||||||
|  |  | ||||||
|  | 	var template = "smtp://%s:%s@%s:%d/?fromAddress=%s&fromName=Watchtower&toAddresses=%s&auth=%s&subject=%s&startTls=Yes&useHTML=No" | ||||||
|  | 	return fmt.Sprintf(template, username, password, host, port, from, to, auth, subject) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertableNotifier | ||||||
|  |  | ||||||
|  | func testURL(builder builderFn, args []string, expectedURL string) { | ||||||
|  |  | ||||||
|  | 	command := cmd.NewRootCommand() | ||||||
|  | 	flags.RegisterNotificationFlags(command) | ||||||
|  | 	command.ParseFlags(args) | ||||||
|  |  | ||||||
|  | 	notifier := builder(command, []log.Level{}) | ||||||
|  | 	actualURL := notifier.GetURL() | ||||||
|  |  | ||||||
|  | 	Expect(actualURL).To(Equal(expectedURL)) | ||||||
|  | } | ||||||
| @@ -35,8 +35,17 @@ type shoutrrrTypeNotifier struct { | |||||||
|  |  | ||||||
| func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | ||||||
| 	flags := c.PersistentFlags() | 	flags := c.PersistentFlags() | ||||||
|  |  | ||||||
| 	urls, _ := flags.GetStringArray("notification-url") | 	urls, _ := flags.GetStringArray("notification-url") | ||||||
|  | 	template := getShoutrrrTemplate(c) | ||||||
|  | 	return createSender(urls, acceptedLogLevels, template) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier { | ||||||
|  | 	template := getShoutrrrTemplate(c) | ||||||
|  | 	return createSender([]string{url}, levels, template) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier { | ||||||
| 	r, err := shoutrrr.CreateSender(urls...) | 	r, err := shoutrrr.CreateSender(urls...) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error()) | 		log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error()) | ||||||
| @@ -45,10 +54,10 @@ func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Noti | |||||||
| 	n := &shoutrrrTypeNotifier{ | 	n := &shoutrrrTypeNotifier{ | ||||||
| 		Urls:      urls, | 		Urls:      urls, | ||||||
| 		Router:    r, | 		Router:    r, | ||||||
| 		logLevels: acceptedLogLevels, |  | ||||||
| 		template:  getShoutrrrTemplate(c), |  | ||||||
| 		messages:  make(chan string, 1), | 		messages:  make(chan string, 1), | ||||||
| 		done:      make(chan bool), | 		done:      make(chan bool), | ||||||
|  | 		logLevels: levels, | ||||||
|  | 		template:  template, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.AddHook(n) | 	log.AddHook(n) | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| package notifications | package notifications | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	shoutrrrSlack "github.com/containrrr/shoutrrr/pkg/services/slack" | ||||||
| 	t "github.com/containrrr/watchtower/pkg/types" | 	t "github.com/containrrr/watchtower/pkg/types" | ||||||
| 	"github.com/johntdyer/slackrus" | 	"github.com/johntdyer/slackrus" | ||||||
| 	log "github.com/sirupsen/logrus" | 	log "github.com/sirupsen/logrus" | ||||||
| @@ -15,7 +18,12 @@ type slackTypeNotifier struct { | |||||||
| 	slackrus.SlackrusHook | 	slackrus.SlackrusHook | ||||||
| } | } | ||||||
|  |  | ||||||
| func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier { | // NewSlackNotifier is a factory function used to generate new instance of the slack notifier type | ||||||
|  | func NewSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
|  | 	return newSlackNotifier(c, acceptedLogLevels) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertableNotifier { | ||||||
| 	flags := c.PersistentFlags() | 	flags := c.PersistentFlags() | ||||||
|  |  | ||||||
| 	hookURL, _ := flags.GetString("notification-slack-hook-url") | 	hookURL, _ := flags.GetString("notification-slack-hook-url") | ||||||
| @@ -23,7 +31,6 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie | |||||||
| 	channel, _ := flags.GetString("notification-slack-channel") | 	channel, _ := flags.GetString("notification-slack-channel") | ||||||
| 	emoji, _ := flags.GetString("notification-slack-icon-emoji") | 	emoji, _ := flags.GetString("notification-slack-icon-emoji") | ||||||
| 	iconURL, _ := flags.GetString("notification-slack-icon-url") | 	iconURL, _ := flags.GetString("notification-slack-icon-url") | ||||||
|  |  | ||||||
| 	n := &slackTypeNotifier{ | 	n := &slackTypeNotifier{ | ||||||
| 		SlackrusHook: slackrus.SlackrusHook{ | 		SlackrusHook: slackrus.SlackrusHook{ | ||||||
| 			HookURL:        hookURL, | 			HookURL:        hookURL, | ||||||
| @@ -34,12 +41,27 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifie | |||||||
| 			AcceptedLevels: acceptedLogLevels, | 			AcceptedLevels: acceptedLogLevels, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	log.AddHook(n) |  | ||||||
| 	return n | 	return n | ||||||
| } | } | ||||||
|  |  | ||||||
| func (s *slackTypeNotifier) StartNotification() {} | func (s *slackTypeNotifier) GetURL() string { | ||||||
|  | 	rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1) | ||||||
|  | 	tokens := strings.Split(rawTokens, "/") | ||||||
|  |  | ||||||
|  | 	conf := &shoutrrrSlack.Config{ | ||||||
|  | 		BotName: s.Username, | ||||||
|  | 		Token: shoutrrrSlack.Token{ | ||||||
|  | 			A: tokens[0], | ||||||
|  | 			B: tokens[1], | ||||||
|  | 			C: tokens[2], | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return conf.GetURL().String() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *slackTypeNotifier) StartNotification() { | ||||||
|  | } | ||||||
|  |  | ||||||
| func (s *slackTypeNotifier) SendNotification() {} | func (s *slackTypeNotifier) SendNotification() {} | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								pkg/types/convertable_notifier.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								pkg/types/convertable_notifier.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | package types | ||||||
|  |  | ||||||
|  | // ConvertableNotifier is a notifier capable of creating a shoutrrr URL | ||||||
|  | type ConvertableNotifier interface { | ||||||
|  | 	Notifier | ||||||
|  | 	GetURL() string | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user