diff --git a/docs/assets/notif/discord-1.png b/docs/assets/notif/discord-1.png new file mode 100644 index 00000000..b06cb062 Binary files /dev/null and b/docs/assets/notif/discord-1.png differ diff --git a/docs/assets/notif/discord-2.png b/docs/assets/notif/discord-2.png new file mode 100644 index 00000000..e35a7579 Binary files /dev/null and b/docs/assets/notif/discord-2.png differ diff --git a/docs/notif/discord.md b/docs/notif/discord.md new file mode 100644 index 00000000..e72d65dd --- /dev/null +++ b/docs/notif/discord.md @@ -0,0 +1,30 @@ +# Discord notifications + +Allow to send notifications to your Discord channel. + +## Configuration + +!!! example "File" + ```yaml + notif: + discord: + webhookURL: https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr + timeout: 10s + ``` + +!!! abstract "Environment variables" + * `DIUN_NOTIF_DISCORD_WEBHOOK` + * `DIUN_NOTIF_DISCORD_TIMEOUT` + +| Name | Default | Description | +|--------------------|---------------|---------------| +| `webhookURL`[^1] | | Discord [incoming webhook URL](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) | +| `timeout` | `10s` | Timeout specifies a time limit for the request to be made | + +## Sample + +![](../assets/notif/discord-1.png) + +![](../assets/notif/discord-2.png) + +[^1]: Value required diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 029da8ea..0411e908 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -50,6 +50,10 @@ func TestLoadFile(t *testing.T) { Password: "guest", Queue: "queue", }, + Discord: &model.NotifDiscord{ + WebhookURL: "https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr", + Timeout: utl.NewDuration(10 * time.Second), + }, Gotify: &model.NotifGotify{ Endpoint: "http://gotify.foo.com", Token: "Token123456", diff --git a/internal/config/fixtures/config.test.yml b/internal/config/fixtures/config.test.yml index 208256ae..c3efe1d9 100644 --- a/internal/config/fixtures/config.test.yml +++ b/internal/config/fixtures/config.test.yml @@ -13,6 +13,9 @@ notif: username: guest password: guest queue: queue + discord: + webhookURL: https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr + timeout: 10s gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/config/fixtures/config.validate.yml b/internal/config/fixtures/config.validate.yml index cfe83ba6..38a5724a 100644 --- a/internal/config/fixtures/config.validate.yml +++ b/internal/config/fixtures/config.validate.yml @@ -13,6 +13,9 @@ notif: username: guest password: guest queue: queue + discord: + webhookURL: https://discordapp.com/api/webhooks/1234567890/Abcd-eFgh-iJklmNo_pqr + timeout: 10s gotify: endpoint: http://gotify.foo.com token: Token123456 diff --git a/internal/model/notif.go b/internal/model/notif.go index becafce9..4a3ffe49 100644 --- a/internal/model/notif.go +++ b/internal/model/notif.go @@ -15,6 +15,7 @@ type NotifEntry struct { // Notif holds data necessary for notification configuration type Notif struct { Amqp *NotifAmqp `yaml:"amqp,omitempty" json:"amqp,omitempty"` + Discord *NotifDiscord `yaml:"discord,omitempty" json:"discord,omitempty"` Gotify *NotifGotify `yaml:"gotify,omitempty" json:"gotify,omitempty"` Mail *NotifMail `yaml:"mail,omitempty" json:"mail,omitempty"` RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty" json:"rocketchat,omitempty"` diff --git a/internal/model/notif_discord.go b/internal/model/notif_discord.go new file mode 100644 index 00000000..a4b94dd9 --- /dev/null +++ b/internal/model/notif_discord.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "github.com/crazy-max/diun/v4/pkg/utl" +) + +// NotifDiscord holds Discord notification configuration details +type NotifDiscord struct { + WebhookURL string `yaml:"webhookURL,omitempty" json:"webhookURL,omitempty" validate:"required"` + Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"` +} + +// GetDefaults gets the default values +func (s *NotifDiscord) GetDefaults() *NotifDiscord { + n := &NotifDiscord{} + n.SetDefaults() + return n +} + +// SetDefaults sets the default values +func (s *NotifDiscord) SetDefaults() { + s.Timeout = utl.NewDuration(10 * time.Second) +} diff --git a/internal/notif/client.go b/internal/notif/client.go index 337ca669..12c70094 100644 --- a/internal/notif/client.go +++ b/internal/notif/client.go @@ -5,6 +5,7 @@ import ( "github.com/crazy-max/diun/v4/internal/model" "github.com/crazy-max/diun/v4/internal/notif/amqp" + "github.com/crazy-max/diun/v4/internal/notif/discord" "github.com/crazy-max/diun/v4/internal/notif/gotify" "github.com/crazy-max/diun/v4/internal/notif/mail" "github.com/crazy-max/diun/v4/internal/notif/notifier" @@ -41,6 +42,9 @@ func New(config *model.Notif, meta model.Meta) (*Client, error) { if config.Amqp != nil { c.notifiers = append(c.notifiers, amqp.New(config.Amqp, meta)) } + if config.Discord != nil { + c.notifiers = append(c.notifiers, discord.New(config.Discord, meta)) + } if config.Gotify != nil { c.notifiers = append(c.notifiers, gotify.New(config.Gotify, meta)) } diff --git a/internal/notif/discord/client.go b/internal/notif/discord/client.go new file mode 100644 index 00000000..693c5fce --- /dev/null +++ b/internal/notif/discord/client.go @@ -0,0 +1,143 @@ +package discord + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "text/template" + "time" + + "github.com/crazy-max/diun/v4/internal/model" + "github.com/crazy-max/diun/v4/internal/notif/notifier" +) + +// Client represents an active discord notification object +type Client struct { + *notifier.Notifier + cfg *model.NotifDiscord + meta model.Meta +} + +// New creates a new discord notification instance +func New(config *model.NotifDiscord, meta model.Meta) notifier.Notifier { + return notifier.Notifier{ + Handler: &Client{ + cfg: config, + meta: meta, + }, + } +} + +// Name returns notifier's name +func (c *Client) Name() string { + return "discord" +} + +// Send creates and sends a discord notification with an entry +// https://discord.com/developers/docs/resources/webhook#execute-webhook +func (c *Client) Send(entry model.NotifEntry) error { + hc := http.Client{ + Timeout: *c.cfg.Timeout, + } + + content := fmt.Sprintf("@here Image update for %s", entry.Image.String()) + if entry.Status == model.ImageStatusNew { + content = fmt.Sprintf("@here New image %s has been added", entry.Image.String()) + } + + tagTpl := "**{{ .Entry.Image.Domain }}/{{ .Entry.Image.Path }}:{{ .Entry.Image.Tag }}**" + if len(entry.Image.HubLink) > 0 { + tagTpl = "[**{{ .Entry.Image.Domain }}/{{ .Entry.Image.Path }}:{{ .Entry.Image.Tag }}**]({{ .Entry.Image.HubLink }})" + } + + var textBuf bytes.Buffer + textTpl := template.Must(template.New("discord").Parse(fmt.Sprintf(`Docker tag %s which you subscribed to through **{{ .Entry.Provider }}** provider has been {{ if (eq .Entry.Status "new") }}newly added{{ else }}updated{{ end }} on **myserver**.`, tagTpl))) + if err := textTpl.Execute(&textBuf, struct { + Meta model.Meta + Entry model.NotifEntry + }{ + Meta: c.meta, + Entry: entry, + }); err != nil { + return err + } + + fields := []EmbedField{ + { + Name: "Hostname", + Value: c.meta.Hostname, + }, + { + Name: "Provider", + Value: entry.Provider, + }, + { + Name: "Created", + Value: entry.Manifest.Created.Format("Jan 02, 2006 15:04:05 UTC"), + }, + { + Name: "Digest", + Value: entry.Manifest.Digest.String(), + }, + { + Name: "Platform", + Value: entry.Manifest.Platform, + }, + } + if len(entry.Image.HubLink) > 0 { + fields = append(fields, EmbedField{ + Name: "HubLink", + Value: entry.Image.HubLink, + }) + } + + dataBuf := new(bytes.Buffer) + if err := json.NewEncoder(dataBuf).Encode(Message{ + Content: content, + Username: c.meta.Name, + AvatarURL: c.meta.Logo, + Embeds: []Embed{ + { + Description: textBuf.String(), + Footer: EmbedFooter{ + Text: fmt.Sprintf("%s © %d %s %s", c.meta.Author, time.Now().Year(), c.meta.Name, c.meta.Version), + IconURL: c.meta.Logo, + }, + Author: EmbedAuthor{ + Name: c.meta.Name, + URL: c.meta.URL, + IconURL: c.meta.Logo, + }, + Fields: fields, + }, + }, + }); err != nil { + return err + } + + u, err := url.Parse(c.cfg.WebhookURL) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", u.String(), dataBuf) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.meta.UserAgent) + + resp, err := hc.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected HTTP status %d: %s", resp.StatusCode, resp.Body) + } + + return nil +} diff --git a/internal/notif/discord/model.go b/internal/notif/discord/model.go new file mode 100644 index 00000000..4a408284 --- /dev/null +++ b/internal/notif/discord/model.go @@ -0,0 +1,52 @@ +package discord + +// Message contains all the information for a message +type Message struct { + Content string `json:"content"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + Embeds []Embed `json:"embeds"` +} + +// Embed contains all the information for an embed object +type Embed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Color int `json:"color,omitempty"` + Footer EmbedFooter `json:"footer,omitempty"` + Image EmbedImage `json:"image,omitempty"` + Thumbnail EmbedThumbnail `json:"thumbnail,omitempty"` + Author EmbedAuthor `json:"author,omitempty"` + Fields []EmbedField `json:"fields,omitempty"` +} + +// EmbedFooter contains all the information for an embed footer object +type EmbedFooter struct { + Text string `json:"text"` + IconURL string `json:"icon_url"` +} + +// EmbedImage contains all the information for an embed image object +type EmbedImage struct { + URL string `json:"url"` +} + +// EmbedThumbnail contains all the information for an embed thumbnail object +type EmbedThumbnail struct { + URL string `json:"url"` +} + +// EmbedAuthor contains all the information for an embed author object +type EmbedAuthor struct { + Name string `json:"name"` + URL string `json:"url"` + IconURL string `json:"icon_url"` +} + +// EmbedField contains all the information for an embed field object +type EmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline,omitempty"` +} diff --git a/mkdocs.yml b/mkdocs.yml index 83cdafe2..57b07221 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - .providers: config/providers.md - Notifications: - Amqp: notif/amqp.md + - Discord: notif/discord.md - Gotify: notif/gotify.md - Mail: notif/mail.md - Rocket.Chat: notif/rocketchat.md