diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index 9a4998a2b94..b7bb3534be9 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -13,13 +13,53 @@ import ( "strings" "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" - - "github.com/grafana/grafana/pkg/components/simplejson" ) +// Constants and models are set according to the official documentation https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params + +type discordEmbedType string + +const ( + discordRichEmbed discordEmbedType = "rich" + + discordMaxEmbeds = 10 + discordMaxMessageLen = 2000 +) + +type discordMessage struct { + Username string `json:"username,omitempty"` + Content string `json:"content"` + AvatarURL string `json:"avatar_url,omitempty"` + Embeds []discordLinkEmbed `json:"embeds,omitempty"` +} + +// discordLinkEmbed implements https://discord.com/developers/docs/resources/channel#embed-object +type discordLinkEmbed struct { + Title string `json:"title,omitempty"` + Type discordEmbedType `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Color int64 `json:"color,omitempty"` + + Footer *discordFooter `json:"footer,omitempty"` + + Image *discordImage `json:"image,omitempty"` +} + +// discordFooter implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure +type discordFooter struct { + Text string `json:"text"` + IconURL string `json:"icon_url,omitempty"` +} + +// discordImage implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure +type discordImage struct { + URL string `json:"url"` +} + type DiscordNotifier struct { *channels.Base log channels.Logger @@ -64,8 +104,6 @@ type discordAttachment struct { state model.AlertStatus } -const DiscordMaxEmbeds = 10 - func DiscordFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { dn, err := newDiscordNotifier(fc) if err != nil { @@ -96,69 +134,78 @@ func newDiscordNotifier(fc channels.FactoryConfig) (*DiscordNotifier, error) { func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { alerts := types.Alerts(as...) - bodyJSON := simplejson.New() + var msg discordMessage if !d.settings.UseDiscordUsername { - bodyJSON.Set("username", "Grafana") + msg.Username = "Grafana" } var tmplErr error tmpl, _ := channels.TmplText(ctx, d.tmpl, as, d.log, &tmplErr) - bodyJSON.Set("content", tmpl(d.settings.Message)) + msg.Content = tmpl(d.settings.Message) if tmplErr != nil { d.log.Warn("failed to template Discord notification content", "error", tmplErr.Error()) // Reset tmplErr for templating other fields. tmplErr = nil } + truncatedMsg, truncated := channels.TruncateInRunes(msg.Content, discordMaxMessageLen) + if truncated { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + d.log.Warn("Truncated content", "key", key, "max_runes", discordMaxMessageLen) + msg.Content = truncatedMsg + } if d.settings.AvatarURL != "" { - bodyJSON.Set("avatar_url", tmpl(d.settings.AvatarURL)) + msg.AvatarURL = tmpl(d.settings.AvatarURL) if tmplErr != nil { d.log.Warn("failed to template Discord Avatar URL", "error", tmplErr.Error(), "fallback", d.settings.AvatarURL) - bodyJSON.Set("avatar_url", d.settings.AvatarURL) + msg.AvatarURL = d.settings.AvatarURL tmplErr = nil } } - footer := map[string]interface{}{ - "text": "Grafana v" + d.appVersion, - "icon_url": "https://grafana.com/static/assets/img/fav32.png", + footer := &discordFooter{ + Text: "Grafana v" + d.appVersion, + IconURL: "https://grafana.com/static/assets/img/fav32.png", } - linkEmbed := simplejson.New() + var linkEmbed discordLinkEmbed - linkEmbed.Set("title", tmpl(d.settings.Title)) + linkEmbed.Title = tmpl(d.settings.Title) if tmplErr != nil { d.log.Warn("failed to template Discord notification title", "error", tmplErr.Error()) // Reset tmplErr for templating other fields. tmplErr = nil } - linkEmbed.Set("footer", footer) - linkEmbed.Set("type", "rich") + linkEmbed.Footer = footer + linkEmbed.Type = discordRichEmbed color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) - linkEmbed.Set("color", color) + linkEmbed.Color = color ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log) - linkEmbed.Set("url", ruleURL) + linkEmbed.URL = ruleURL - embeds := []interface{}{linkEmbed} + embeds := []discordLinkEmbed{linkEmbed} - attachments := d.constructAttachments(ctx, as, DiscordMaxEmbeds-1) + attachments := d.constructAttachments(ctx, as, discordMaxEmbeds-1) for _, a := range attachments { color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) - embed := map[string]interface{}{ - "image": map[string]interface{}{ - "url": a.url, + embed := discordLinkEmbed{ + Image: &discordImage{ + URL: a.url, }, - "color": color, - "title": a.alertName, + Color: color, + Title: a.alertName, } embeds = append(embeds, embed) } - bodyJSON.Set("embeds", embeds) + msg.Embeds = embeds if tmplErr != nil { d.log.Warn("failed to template Discord message", "error", tmplErr.Error()) @@ -171,7 +218,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, u = d.settings.WebhookURL } - body, err := json.Marshal(bodyJSON) + body, err := json.Marshal(msg) if err != nil { return false, err } diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index 9df156c94eb..3af76eae324 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand" "net/url" + "strings" "testing" "github.com/grafana/alerting/alerting/notifier/channels" @@ -283,6 +284,36 @@ func TestDiscordNotifier(t *testing.T) { }, expMsgError: nil, }, + { + name: "Should truncate too long messages", + settings: fmt.Sprintf(`{ + "url": "http://localhost", + "use_discord_username": true, + "message": "%s" + }`, strings.Repeat("Y", discordMaxMessageLen+rand.Intn(100)+1)), + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "content": strings.Repeat("Y", discordMaxMessageLen-1) + "…", + "embeds": []interface{}{map[string]interface{}{ + "color": 1.4037554e+07, + "footer": map[string]interface{}{ + "icon_url": "https://grafana.com/static/assets/img/fav32.png", + "text": "Grafana v" + appVersion, + }, + "title": "[FIRING:1] (val1)", + "url": "http://localhost/alerting/list", + "type": "rich", + }}, + }, + expMsgError: nil, + }, } for _, c := range cases {