Fix mentioning Slack users/groups (#21734)

* alerting/slack: Allow mentioning users, groups, and channels separately
This commit is contained in:
Arve Knudsen 2020-02-11 21:43:28 +01:00 committed by GitHub
parent 717e1a3b2b
commit 6f09bc9fb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 156 additions and 55 deletions

View File

@ -1,6 +1,7 @@
# 6.7.0 (unreleased) # 6.7.0 (unreleased)
## Breaking changes ## Breaking changes
* **Slack**: Removed _Mention_ setting and instead introduce _Mention Users_, _Mention Groups_, and _Mention Channel_. The first two settings require user and group IDs, respectively. This change was necessary because the way of mentioning via the Slack API [changed](https://api.slack.com/changelog/2017-09-the-one-about-usernames) and mentions in Slack notifications no longer worked.
### Notice about changes in backendSrv for plugin authors ### Notice about changes in backendSrv for plugin authors

View File

@ -351,7 +351,9 @@ The following sections detail the supported settings for each alert notification
| icon_emoji | | icon_emoji |
| icon_url | | icon_url |
| uploadImage | | uploadImage |
| mention | | mentionUsers |
| mentionGroups |
| mentionChannel |
| token | | token |
#### Alert notification `victorops` #### Alert notification `victorops`

View File

@ -75,20 +75,22 @@ able to access the image.
{{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}} {{< imgbox max-width="40%" img="/img/docs/v4/slack_notification.png" caption="Alerting Slack Notification" >}}
To set up slack you need to configure an incoming webhook url at slack. You can follow their guide on how To set up Slack, you need to configure an incoming Slack webhook URL. You can follow
to do that [here](https://api.slack.com/incoming-webhooks). If you want to include screenshots of the firing alerts [their guide](https://api.slack.com/incoming-webhooks) on how to do that. If you want to include screenshots of the
in the Slack messages you have to configure either the [external image destination](#external-image-store) in Grafana, firing alerts in the Slack messages you have to configure either the [external image destination](#external-image-store)
or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integration and use the token provided in Grafana, or a bot integration via Slack Apps. Follow Slack's guide to set up a bot integration and use the token
(https://api.slack.com/bot-users), which starts with "xoxb". provided (https://api.slack.com/bot-users), which starts with "xoxb".
Setting | Description Setting | Description
---------- | ----------- ---------- | -----------
Url | Slack incoming webhook url. Url | Slack incoming webhook URL.
Username | Set the username for the bot's message. Username | Set the username for the bot's message.
Recipient | Allows you to override the Slack recipient. Recipient | Allows you to override the Slack recipient.
Icon emoji | Provide an emoji to use as the icon for the bot's message. Ex :smile: Icon emoji | Provide an emoji to use as the icon for the bot's message. Ex :smile:
Icon URL | Provide a url to an image to use as the icon for the bot's message. Icon URL | Provide a url to an image to use as the icon for the bot's message.
Mention | make it possible to include a mention in the Slack notification sent by Grafana. Ex @here or @channel Mention Users | Optionally mention one or more users in the Slack notification sent by Grafana. You have to refer to users, comma-separated, via their corresponding Slack IDs (which you can find by clicking the overflow button on each user's Slack profile).
Mention Groups | Optionally mention one or more groups in the Slack notification sent by Grafana. You have to refer to groups, comma-separated, via their corresponding Slack IDs (which you can get from each group's Slack profile URL).
Mention Channel | Optionally mention either all channel members or just active ones.
Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination. Token | If provided, Grafana will upload the generated image via Slack's file.upload API method, not the external image destination.
If you are using the token for a slack bot, then you have to invite the bot to the channel you want to send notifications and add the channel to the recipient field. If you are using the token for a slack bot, then you have to invite the bot to the channel you want to send notifications and add the channel to the recipient field.

View File

@ -3,10 +3,12 @@ package notifiers
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
@ -25,11 +27,11 @@ func init() {
OptionsTemplate: ` OptionsTemplate: `
<h3 class="page-heading">Slack settings</h3> <h3 class="page-heading">Slack settings</h3>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Url</span> <span class="gf-form-label width-8">Url</span>
<input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Slack incoming webhook url"></input> <input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Slack incoming webhook url"></input>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Recipient</span> <span class="gf-form-label width-8">Recipient</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.recipient" ng-model="ctrl.model.settings.recipient"
@ -40,7 +42,7 @@ func init() {
</info-popover> </info-popover>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Username</span> <span class="gf-form-label width-8">Username</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.username" ng-model="ctrl.model.settings.username"
@ -51,7 +53,7 @@ func init() {
</info-popover> </info-popover>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Icon emoji</span> <span class="gf-form-label width-8">Icon emoji</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.icon_emoji" ng-model="ctrl.model.settings.icon_emoji"
@ -62,7 +64,7 @@ func init() {
</info-popover> </info-popover>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Icon URL</span> <span class="gf-form-label width-8">Icon URL</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.icon_url" ng-model="ctrl.model.settings.icon_url"
@ -73,25 +75,50 @@ func init() {
</info-popover> </info-popover>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Mention</span> <span class="gf-form-label width-8">Mention Users</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mention" ng-model="ctrl.model.settings.mentionUsers"
data-placement="right"> data-placement="right">
</input> </input>
<info-popover mode="right-absolute"> <info-popover mode="right-absolute">
Mention a user or a group using @ when notifying in a channel Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)
</info-popover> </info-popover>
</div> </div>
<div class="gf-form max-width-30"> <div class="gf-form max-width-30">
<span class="gf-form-label width-6">Token</span> <span class="gf-form-label width-8">Mention Groups</span>
<input type="text"
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mentionGroups"
data-placement="right">
</input>
<info-popover mode="right-absolute">
Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Mention Channel</span>
<select
class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.mentionChannel"
data-placement="right">
<option value="">Disabled</option>
<option value="here">Every active channel member</option>
<option value="channel">Every channel member</option>
</select>
<info-popover mode="right-absolute">
Mention whole channel or just active members when notifying
</info-popover>
</div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-8">Token</span>
<input type="text" <input type="text"
class="gf-form-input max-width-30" class="gf-form-input max-width-30"
ng-model="ctrl.model.settings.token" ng-model="ctrl.model.settings.token"
data-placement="right"> data-placement="right">
</input> </input>
<info-popover mode="right-absolute"> <info-popover mode="right-absolute">
Provide a bot token to use the Slack file.upload API (starts with "xoxb"). Specify #channel-name or @username in Recipient for this to work Provide a bot token to use the Slack file.upload API (starts with "xoxb"). Specify #channel-name or @username in Recipient for this to work
</info-popover> </info-popover>
</div> </div>
`, `,
@ -110,21 +137,43 @@ func NewSlackNotifier(model *models.AlertNotification) (alerting.Notifier, error
username := model.Settings.Get("username").MustString() username := model.Settings.Get("username").MustString()
iconEmoji := model.Settings.Get("icon_emoji").MustString() iconEmoji := model.Settings.Get("icon_emoji").MustString()
iconURL := model.Settings.Get("icon_url").MustString() iconURL := model.Settings.Get("icon_url").MustString()
mention := model.Settings.Get("mention").MustString() mentionUsersStr := model.Settings.Get("mentionUsers").MustString()
mentionGroupsStr := model.Settings.Get("mentionGroups").MustString()
mentionChannel := model.Settings.Get("mentionChannel").MustString()
token := model.Settings.Get("token").MustString() token := model.Settings.Get("token").MustString()
uploadImage := model.Settings.Get("uploadImage").MustBool(true) uploadImage := model.Settings.Get("uploadImage").MustBool(true)
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
return nil, fmt.Errorf(fmt.Sprintf("invalid value for mentionChannel: %q", mentionChannel))
}
mentionUsers := []string{}
for _, u := range strings.Split(mentionUsersStr, ",") {
u = strings.TrimSpace(u)
if u != "" {
mentionUsers = append(mentionUsers, u)
}
}
mentionGroups := []string{}
for _, g := range strings.Split(mentionGroupsStr, ",") {
g = strings.TrimSpace(g)
if g != "" {
mentionGroups = append(mentionGroups, g)
}
}
return &SlackNotifier{ return &SlackNotifier{
NotifierBase: NewNotifierBase(model), NotifierBase: NewNotifierBase(model),
URL: url, URL: url,
Recipient: recipient, Recipient: recipient,
Username: username, Username: username,
IconEmoji: iconEmoji, IconEmoji: iconEmoji,
IconURL: iconURL, IconURL: iconURL,
Mention: mention, MentionUsers: mentionUsers,
Token: token, MentionGroups: mentionGroups,
Upload: uploadImage, MentionChannel: mentionChannel,
log: log.New("alerting.notifier.slack"), Token: token,
Upload: uploadImage,
log: log.New("alerting.notifier.slack"),
}, nil }, nil
} }
@ -132,15 +181,17 @@ func NewSlackNotifier(model *models.AlertNotification) (alerting.Notifier, error
// alert notification to Slack. // alert notification to Slack.
type SlackNotifier struct { type SlackNotifier struct {
NotifierBase NotifierBase
URL string URL string
Recipient string Recipient string
Username string Username string
IconEmoji string IconEmoji string
IconURL string IconURL string
Mention string MentionUsers []string
Token string MentionGroups []string
Upload bool MentionChannel string
log log.Logger Token string
Upload bool
log log.Logger
} }
// Notify send alert notification to Slack. // Notify send alert notification to Slack.
@ -174,9 +225,31 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
}) })
} }
message := sn.Mention mentionsBuilder := strings.Builder{}
appendSpace := func() {
if mentionsBuilder.Len() > 0 {
mentionsBuilder.WriteString(" ")
}
}
mentionChannel := strings.TrimSpace(sn.MentionChannel)
if mentionChannel != "" {
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel))
}
if len(sn.MentionGroups) > 0 {
appendSpace()
for _, g := range sn.MentionGroups {
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", g))
}
}
if len(sn.MentionUsers) > 0 {
appendSpace()
for _, u := range sn.MentionUsers {
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u))
}
}
msg := ""
if evalContext.Rule.State != models.AlertStateOK { //don't add message when going back to alert state ok. if evalContext.Rule.State != models.AlertStateOK { //don't add message when going back to alert state ok.
message += " " + evalContext.Rule.Message msg = evalContext.Rule.Message
} }
imageURL := "" imageURL := ""
// default to file.upload API method if a token is provided // default to file.upload API method if a token is provided
@ -184,14 +257,28 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
imageURL = evalContext.ImagePublicURL imageURL = evalContext.ImagePublicURL
} }
var blocks []map[string]interface{}
if mentionsBuilder.Len() > 0 {
blocks = []map[string]interface{}{
{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": mentionsBuilder.String(),
},
},
}
}
body := map[string]interface{}{ body := map[string]interface{}{
"text": evalContext.GetNotificationTitle(),
"blocks": blocks,
"attachments": []map[string]interface{}{ "attachments": []map[string]interface{}{
{ {
"fallback": evalContext.GetNotificationTitle(),
"color": evalContext.GetStateModel().Color, "color": evalContext.GetStateModel().Color,
"title": evalContext.GetNotificationTitle(), "title": evalContext.GetNotificationTitle(),
"title_link": ruleURL, "title_link": ruleURL,
"text": message, "text": msg,
"fallback": evalContext.GetNotificationTitle(),
"fields": fields, "fields": fields,
"image_url": imageURL, "image_url": imageURL,
"footer": "Grafana v" + setting.BuildVersion, "footer": "Grafana v" + setting.BuildVersion,
@ -215,7 +302,10 @@ func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
if sn.IconURL != "" { if sn.IconURL != "" {
body["icon_url"] = sn.IconURL body["icon_url"] = sn.IconURL
} }
data, _ := json.Marshal(&body) data, err := json.Marshal(&body)
if err != nil {
return err
}
cmd := &models.SendWebhookSync{Url: sn.URL, Body: string(data)} cmd := &models.SendWebhookSync{Url: sn.URL, Body: string(data)}
if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
sn.log.Error("Failed to send slack notification", "error", err, "webhook", sn.Name) sn.log.Error("Failed to send slack notification", "error", err, "webhook", sn.Name)

View File

@ -51,21 +51,25 @@ func TestSlackNotifier(t *testing.T) {
So(slackNotifier.Username, ShouldEqual, "") So(slackNotifier.Username, ShouldEqual, "")
So(slackNotifier.IconEmoji, ShouldEqual, "") So(slackNotifier.IconEmoji, ShouldEqual, "")
So(slackNotifier.IconURL, ShouldEqual, "") So(slackNotifier.IconURL, ShouldEqual, "")
So(slackNotifier.Mention, ShouldEqual, "") So(slackNotifier.MentionUsers, ShouldResemble, []string{})
So(slackNotifier.MentionGroups, ShouldResemble, []string{})
So(slackNotifier.MentionChannel, ShouldEqual, "")
So(slackNotifier.Token, ShouldEqual, "") So(slackNotifier.Token, ShouldEqual, "")
}) })
Convey("from settings with Recipient, Username, IconEmoji, IconUrl, Mention, and Token", func() { Convey("from settings with Recipient, Username, IconEmoji, IconUrl, MentionUsers, MentionGroups, MentionChannel, and Token", func() {
json := ` json := `
{ {
"url": "http://google.com", "url": "http://google.com",
"recipient": "#ds-opentsdb", "recipient": "#ds-opentsdb",
"username": "Grafana Alerts", "username": "Grafana Alerts",
"icon_emoji": ":smile:", "icon_emoji": ":smile:",
"icon_url": "https://grafana.com/img/fav32.png", "icon_url": "https://grafana.com/img/fav32.png",
"mention": "@carl", "mentionUsers": "user1, user2",
"token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX" "mentionGroups": "group1, group2",
}` "mentionChannel": "here",
"token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX"
}`
settingsJSON, _ := simplejson.NewJson([]byte(json)) settingsJSON, _ := simplejson.NewJson([]byte(json))
model := &models.AlertNotification{ model := &models.AlertNotification{
@ -85,7 +89,9 @@ func TestSlackNotifier(t *testing.T) {
So(slackNotifier.Username, ShouldEqual, "Grafana Alerts") So(slackNotifier.Username, ShouldEqual, "Grafana Alerts")
So(slackNotifier.IconEmoji, ShouldEqual, ":smile:") So(slackNotifier.IconEmoji, ShouldEqual, ":smile:")
So(slackNotifier.IconURL, ShouldEqual, "https://grafana.com/img/fav32.png") So(slackNotifier.IconURL, ShouldEqual, "https://grafana.com/img/fav32.png")
So(slackNotifier.Mention, ShouldEqual, "@carl") So(slackNotifier.MentionUsers, ShouldResemble, []string{"user1", "user2"})
So(slackNotifier.MentionGroups, ShouldResemble, []string{"group1", "group2"})
So(slackNotifier.MentionChannel, ShouldEqual, "here")
So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX") So(slackNotifier.Token, ShouldEqual, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX")
}) })
}) })