Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,17 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
}
sc.APIURL = (*amcommoncfg.SecretURL)(sc.AppURL)
}
// update_message and post_updates_to_thread require the bot-token API.
// The endpoint can only be verified for URLs known at load time;
// api_url_file is read at notification time and is accepted as-is.
if len(sc.APIURLFile) == 0 && (sc.APIURL == nil || sc.APIURL.String() != "https://slack.com/api/chat.postMessage") {
if sc.UpdateMessage {
return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
}
if sc.PostUpdatesToThread {
return errors.New("post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
}
}
}
for _, poc := range rcv.PushoverConfigs {
if poc == nil {
Expand Down
35 changes: 35 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,41 @@ func TestSlackUpdateMessageWebhookURL(t *testing.T) {
}
}

func TestSlackPostUpdatesToThreadWebhookURL(t *testing.T) {
_, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-webhook.yml")
if err == nil {
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-webhook", err)
}
if err.Error() != "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" {
t.Errorf("Expected: %s\nGot: %s", "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error())
}
}

func TestSlackUpdateMessageWithAppToken(t *testing.T) {
// The app token flow resolves api_url to the Slack bot API during global
// config resolution, so update_message must be accepted with it.
_, err := LoadFile("testdata/conf.slack-update-message-and-app-token.yml")
if err != nil {
t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-update-message-and-app-token.yml", err)
}
}

func TestSlackPostUpdatesToThreadWithAppToken(t *testing.T) {
_, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-app-token.yml")
if err != nil {
t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-app-token.yml", err)
}
}

func TestSlackUpdateMessageWithAPIURLFile(t *testing.T) {
// api_url_file is read at notification time, so its content cannot be
// verified at load time and the configuration must be accepted.
_, err := LoadFile("testdata/conf.slack-update-message-and-api-url-file.yml")
if err != nil {
t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-update-message-and-api-url-file.yml", err)
}
}

func TestSlackGlobalAppToken(t *testing.T) {
conf, err := LoadFile("testdata/conf.slack-default-app-token.yml")
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
# bot token flow without explicit api_url
- channel: '#alerts1'
text: 'test'
send_resolved: true
app_token: 'xoxb-some-token'
post_updates_to_thread: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
# use global
- channel: '#alerts1'
text: 'test'
send_resolved: true
# trying to use webhook urls with post_updates_to_thread
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
post_updates_to_thread: true
13 changes: 13 additions & 0 deletions config/testdata/conf.slack-update-message-and-api-url-file.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
# api_url_file is read at notification time; accepted at load time
- channel: '#alerts1'
text: 'test'
send_resolved: true
api_url_file: '/etc/slack/api_url'
update_message: true
post_updates_to_thread: true
12 changes: 12 additions & 0 deletions config/testdata/conf.slack-update-message-and-app-token.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
# bot token flow without explicit api_url
- channel: '#alerts1'
text: 'test'
send_resolved: true
app_token: 'xoxb-some-token'
update_message: true
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1761,6 +1761,16 @@ fields:
# Enables updating existing Slack messages instead of creating new ones on alert state change.
# Webhook URLs do not support updates.
[ update_message: <boolean> | default = false ]

# Posts subsequent notifications for an alert group as replies in the thread of the
# initial message instead of new channel messages. When combined with update_message,
# the initial message is updated in place and a reply is also posted to its thread,
# except for notifications triggered only by repeat_interval: the updated message
# already carries the current state, so it is not copied into the thread again.
# Requires a Slack app with a bot token (chat:write scope) and api_url set to
# https://slack.com/api/chat.postMessage. Incoming webhooks cannot be used, they do
# not return the message identifiers a thread needs.
[ post_updates_to_thread: <boolean> | default = false ]
```

#### `<action_config>` (Slack)
Expand Down
15 changes: 11 additions & 4 deletions notify/slack/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ type SlackConfig struct {
// Requires bot token with chat:write scope. Webhook URLs do not support updates.

UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"`

// PostUpdatesToThread enables posting subsequent notifications for an alert group
// as replies in the thread of the initial message. When combined with UpdateMessage,
// the initial message is updated in place and a reply is also posted to its thread.
// Requires bot token with chat:write scope. Webhook URLs do not support threads.

PostUpdatesToThread bool `yaml:"post_updates_to_thread" json:"post_updates_to_thread,omitempty"`
// Timeout is the maximum time allowed to invoke the slack. Setting this to 0
// does not impose a timeout.
Timeout time.Duration `yaml:"timeout" json:"timeout"`
Expand All @@ -191,6 +198,10 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error {
return c.Validate()
}

// Validate checks that the Slack configuration endpoints and credentials are
// mutually consistent. The endpoint requirements of update_message and
// post_updates_to_thread are checked during global config resolution, once
// api_url has been resolved from the global section or an app token.
func (c *SlackConfig) Validate() error {
if c.APIURL != nil && len(c.APIURLFile) > 0 {
return errors.New("at most one of api_url & api_url_file must be configured")
Expand All @@ -202,9 +213,5 @@ func (c *SlackConfig) Validate() error {
return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured")
}

if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
}

return nil
}
100 changes: 87 additions & 13 deletions notify/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import (
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
const maxTitleLenRunes = 1024

// chatPostMessageURL is the only api_url that supports message updates and
// threads, both of which need the message identifiers returned by the bot-token
// Web API. Incoming webhooks return no identifiers.
const chatPostMessageURL = "https://slack.com/api/chat.postMessage"

// New returns a new Slack notification handler.
func New(c *SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...)
Expand Down Expand Up @@ -153,6 +158,10 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify
u = strings.TrimSpace(string(content))
}

if err := requireBotAPIURL(n.conf, u); err != nil {
return notify.Unrecoverable(err, notify.DefaultReason)
}

if n.conf.Timeout > 0 {
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured slack timeout reached (%s)", n.conf.Timeout))
defer cancel()
Expand All @@ -169,27 +178,92 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify
Attachments: []attachment{*att},
}

// If a notification for this alert group has already been sent and `update_message` config is set
// edit API endpoint and payload to update notification instead of sending a new one.
// If a notification for this alert group has already been sent, `update_message`
// edits the initial message instead of sending a new one and `post_updates_to_thread`
// posts the notification as a reply in the initial message's thread.
var store *nflog.Store
var threadTs, channelId string

if n.conf.UpdateMessage {
if n.conf.UpdateMessage || n.conf.PostUpdatesToThread {
var ok bool
store, ok = notify.NflogStore(ctx)
if !ok {
logger.Warn("cannot create NflogStore, updatable messages will be disabled.")
logger.Warn("cannot create NflogStore, updatable and threaded messages will be disabled.")
} else {
threadTs, _ := store.GetStr("threadTs")
channelId, _ := store.GetStr("channelId")
logger.Debug("attempt recovering threadTs and channelId to update an existing message", "threadTs", threadTs, "channelId", channelId)
if threadTs != "" && channelId != "" {
u = "https://slack.com/api/chat.update"
req.Timestamp = threadTs
req.Channel = channelId
logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId)
}
threadTs, _ = store.GetStr("threadTs")
channelId, _ = store.GetStr("channelId")
logger.Debug("attempt recovering threadTs and channelId of the initial message", "threadTs", threadTs, "channelId", channelId)
}
}

postURL := u
initialMessageSent := threadTs != "" && channelId != ""
if initialMessageSent {
switch {
case n.conf.UpdateMessage:
u = "https://slack.com/api/chat.update"
req.Timestamp = threadTs
req.Channel = channelId
logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId)
case n.conf.PostUpdatesToThread:
req.ThreadTimestamp = threadTs
req.Channel = channelId
logger.Debug("posting to thread of previously sent message", "threadTs", threadTs, "channelId", channelId)
}
}

// The thread reply must not overwrite the initial message's timestamp in the
// nflog store, so no store is passed when the request targets a thread.
responseStore := store
if initialMessageSent {
responseStore = nil
}
if verdict := n.postRequest(ctx, u, req, responseStore); verdict.Err() != nil {
return verdict
}

// When update_message and post_updates_to_thread are combined, the initial
// message was just updated in place; additionally post a reply to its thread,
// unless nothing changed in the alert group: a notification triggered only by
// repeat_interval would add a copy of the message that was just updated.
if initialMessageSent && n.conf.UpdateMessage && n.conf.PostUpdatesToThread && !repeatIntervalOnly(ctx) {
threadReq := *req
threadReq.Timestamp = ""
threadReq.ThreadTimestamp = threadTs
logger.Debug("posting update to thread of previously sent message", "threadTs", threadTs, "channelId", channelId)
return n.postRequest(ctx, postURL, &threadReq, nil)
}

return notify.Success()
}

// requireBotAPIURL rejects a resolved api_url that cannot support message
// updates or threads. Config loading already performs this check for api_url
// and app_token; api_url_file can only be checked here, because its content is
// read at notification time.
func requireBotAPIURL(conf *SlackConfig, u string) error {
if !conf.UpdateMessage && !conf.PostUpdatesToThread {
return nil
}
if u == chatPostMessageURL {
return nil
}
if conf.UpdateMessage {
return fmt.Errorf("update_message can only be used with bot tokens. api_url must be set to %s", chatPostMessageURL)
}
return fmt.Errorf("post_updates_to_thread can only be used with bot tokens. api_url must be set to %s", chatPostMessageURL)
}

// repeatIntervalOnly reports whether the notification was triggered solely by
// repeat_interval elapsing, meaning the state of the alert group is unchanged.
func repeatIntervalOnly(ctx context.Context) bool {
reason, ok := notify.NotificationReason(ctx)
return ok && reason == notify.ReasonRepeatIntervalElapsed
}

// postRequest encodes and sends a single request to the Slack API, classifies
// errors as retriable or not, and hands the response to slackResponseHandler.
func (n *Notifier) postRequest(ctx context.Context, u string, req *request, store *nflog.Store) notify.NotifyVerdict {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(req); err != nil {
return notify.Unrecoverable(err, notify.DefaultReason)
Expand Down
Loading