From 40088c8c22b7f480b7b0040e9c9b4f14033b0097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20GLON?= Date: Thu, 3 Sep 2026 17:13:09 +0200 Subject: [PATCH 1/5] notify/slack: add post_updates_to_thread option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post subsequent notifications for an alert group as replies in the thread of the initial Slack 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. The root message timestamp and channel are recovered from the nflog receiver data store introduced for update_message, so threading works across restarts and in clustered setups. Fixes #3221 Signed-off-by: Sébastien GLON --- config/config_test.go | 10 ++ ...ack-post-updates-to-thread-and-webhook.yml | 13 ++ docs/configuration.md | 6 + notify/slack/config.go | 11 ++ notify/slack/slack.go | 65 ++++++++-- notify/slack/slack_test.go | 118 ++++++++++++++++++ notify/slack/types.go | 17 +-- 7 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml diff --git a/config/config_test.go b/config/config_test.go index 76264a1778..a069b02f29 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1322,6 +1322,16 @@ 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 TestSlackGlobalAppToken(t *testing.T) { conf, err := LoadFile("testdata/conf.slack-default-app-token.yml") if err != nil { diff --git a/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml b/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml new file mode 100644 index 0000000000..b39e538ed9 --- /dev/null +++ b/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 86b137a7e2..e7501d4896 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1761,6 +1761,12 @@ fields: # Enables updating existing Slack messages instead of creating new ones on alert state change. # Webhook URLs do not support updates. [ update_message: | 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. +# Webhook URLs do not support threads. +[ post_updates_to_thread: | default = false ] ``` #### `` (Slack) diff --git a/notify/slack/config.go b/notify/slack/config.go index c2e67af53e..28b0f89ee8 100644 --- a/notify/slack/config.go +++ b/notify/slack/config.go @@ -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"` @@ -206,5 +213,9 @@ func (c *SlackConfig) Validate() error { 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 c.PostUpdatesToThread && (c.APIURL == nil || c.APIURL.String() != "https://slack.com/api/chat.postMessage") { + 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") + } + return nil } diff --git a/notify/slack/slack.go b/notify/slack/slack.go index 2a3b86f8d9..467c0c92e8 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -169,27 +169,66 @@ 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. + if initialMessageSent && n.conf.UpdateMessage && n.conf.PostUpdatesToThread { + 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() +} + +// 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) diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 1ffee08c09..8fb326bad8 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -33,6 +33,7 @@ import ( amcommoncfg "github.com/prometheus/alertmanager/config/common" + "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" @@ -396,3 +397,120 @@ func TestNotifier_Notify_RetryAfterDelay(t *testing.T) { require.Error(t, verdict.Err()) require.Equal(t, 1*time.Second, verdict.Delay()) } + +func TestSlackPostUpdatesToThread(t *testing.T) { + type capturedRequest struct { + url string + body map[string]any + } + + newTestNotifier := func(t *testing.T, conf *SlackConfig, captured *[]capturedRequest, respTs string) *Notifier { + t.Helper() + u, _ := url.Parse("https://slack.com/api/chat.postMessage") + conf.APIURL = &amcommoncfg.SecretURL{URL: u} + conf.Channel = "#test-channel" + conf.HTTPConfig = &commoncfg.HTTPClientConfig{} + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + tmpl.ExternalURL = u + + notifier, err := New(conf, tmpl, slog.New(slog.DiscardHandler)) + require.NoError(t, err) + + notifier.postJSONFunc = func(ctx context.Context, client *http.Client, reqURL string, body io.Reader) (*http.Response, error) { + var decoded map[string]any + require.NoError(t, json.NewDecoder(body).Decode(&decoded)) + *captured = append(*captured, capturedRequest{url: reqURL, body: decoded}) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok": true, "channel": "C123", "ts": "` + respTs + `"}`)), + } + return resp, nil + } + return notifier + } + + newCtx := func(store *nflog.Store) context.Context { + ctx := notify.WithGroupKey(context.Background(), "test-group-key") + return notify.WithNflogStore(ctx, store) + } + + t.Run("first notification posts to channel and stores thread ts", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "111.222") + store := nflog.NewStore(nil) + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.NotContains(t, captured[0].body, "ts") + require.NotContains(t, captured[0].body, "thread_ts") + + threadTs, _ := store.GetStr("threadTs") + channelId, _ := store.GetStr("channelId") + require.Equal(t, "111.222", threadTs) + require.Equal(t, "C123", channelId) + }) + + t.Run("subsequent notification updates message and posts thread reply", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 2) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + require.Equal(t, "C123", captured[0].body["channel"]) + require.NotContains(t, captured[0].body, "thread_ts") + + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[1].url) + require.Equal(t, "111.222", captured[1].body["thread_ts"]) + require.Equal(t, "C123", captured[1].body["channel"]) + require.NotContains(t, captured[1].body, "ts") + + // The stored root message ts must not be overwritten by the responses. + threadTs, _ := store.GetStr("threadTs") + require.Equal(t, "111.222", threadTs) + }) + + t.Run("subsequent notification posts only to thread without update_message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.Equal(t, "111.222", captured[0].body["thread_ts"]) + require.Equal(t, "C123", captured[0].body["channel"]) + require.NotContains(t, captured[0].body, "ts") + + threadTs, _ := store.GetStr("threadTs") + require.Equal(t, "111.222", threadTs) + }) + + t.Run("update_message alone does not post thread reply", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + require.NotContains(t, captured[0].body, "thread_ts") + }) +} diff --git a/notify/slack/types.go b/notify/slack/types.go index 4427b055fe..19971d3b2f 100644 --- a/notify/slack/types.go +++ b/notify/slack/types.go @@ -36,14 +36,15 @@ type Notifier struct { // request is the request for sending a Slack notification. type request struct { - Channel string `json:"channel,omitempty"` - Timestamp string `json:"ts,omitempty"` - Username string `json:"username,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty"` - IconURL string `json:"icon_url,omitempty"` - LinkNames bool `json:"link_names,omitempty"` - Text string `json:"text,omitempty"` - Attachments []attachment `json:"attachments"` + Channel string `json:"channel,omitempty"` + Timestamp string `json:"ts,omitempty"` + ThreadTimestamp string `json:"thread_ts,omitempty"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + LinkNames bool `json:"link_names,omitempty"` + Text string `json:"text,omitempty"` + Attachments []attachment `json:"attachments"` } // attachment is used to display a richly formatted message block. From 077ac35aa88e9bb1862f18e35f64b35600c9b55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20GLON?= Date: Thu, 3 Sep 2026 17:50:43 +0200 Subject: [PATCH 2/5] config: reject update_message/post_updates_to_thread without api_url instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validating a Slack configuration with update_message enabled but no api_url set (e.g. when using app_token) dereferenced a nil APIURL and crashed config loading. Return the existing validation error instead, and add a doc comment on Validate. Signed-off-by: Sébastien GLON --- config/config_test.go | 20 +++++++++++++++++++ ...k-post-updates-to-thread-and-app-token.yml | 12 +++++++++++ ...onf.slack-update-message-and-app-token.yml | 12 +++++++++++ notify/slack/config.go | 4 +++- 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml create mode 100644 config/testdata/conf.slack-update-message-and-app-token.yml diff --git a/config/config_test.go b/config/config_test.go index a069b02f29..14418cc11c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1332,6 +1332,26 @@ func TestSlackPostUpdatesToThreadWebhookURL(t *testing.T) { } } +func TestSlackUpdateMessageWithoutAPIURL(t *testing.T) { + _, err := LoadFile("testdata/conf.slack-update-message-and-app-token.yml") + if err == nil { + t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-app-token", err) + } + if err.Error() != "update_message 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", "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error()) + } +} + +func TestSlackPostUpdatesToThreadWithoutAPIURL(t *testing.T) { + _, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-app-token.yml") + if err == nil { + t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-app-token", 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 TestSlackGlobalAppToken(t *testing.T) { conf, err := LoadFile("testdata/conf.slack-default-app-token.yml") if err != nil { diff --git a/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml b/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml new file mode 100644 index 0000000000..236551c77b --- /dev/null +++ b/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml @@ -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 diff --git a/config/testdata/conf.slack-update-message-and-app-token.yml b/config/testdata/conf.slack-update-message-and-app-token.yml new file mode 100644 index 0000000000..d8d69c2fa6 --- /dev/null +++ b/config/testdata/conf.slack-update-message-and-app-token.yml @@ -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 diff --git a/notify/slack/config.go b/notify/slack/config.go index 28b0f89ee8..98d7e4c212 100644 --- a/notify/slack/config.go +++ b/notify/slack/config.go @@ -198,6 +198,8 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { return c.Validate() } +// Validate checks that the Slack configuration endpoints and credentials are +// mutually consistent and that message-updating options use the bot-token flow. 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") @@ -209,7 +211,7 @@ 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" { + if c.UpdateMessage && (c.APIURL == nil || 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") } From 504e1ec93c0b55278b6b297cc95abe394c3d87e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20GLON?= Date: Thu, 3 Sep 2026 18:09:10 +0200 Subject: [PATCH 3/5] config: validate update_message/post_updates_to_thread endpoint after global resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking the endpoint in SlackConfig.Validate rejected valid configurations whose api_url is only known after unmarshalling: api_url provided via api_url_file (read at notification time), via the global slack_api_url, or resolved from an app token. Move the check to global config resolution where the effective URL is known, and accept api_url_file configurations as-is since their content cannot be verified at load time. Signed-off-by: Sébastien GLON --- config/config.go | 11 ++++++++ config/config_test.go | 27 +++++++++++-------- ....slack-update-message-and-api-url-file.yml | 13 +++++++++ notify/slack/config.go | 12 +++------ 4 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 config/testdata/conf.slack-update-message-and-api-url-file.yml diff --git a/config/config.go b/config/config.go index a66ea7ec17..bbea05c88b 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/config/config_test.go b/config/config_test.go index 14418cc11c..628335f269 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1332,23 +1332,28 @@ func TestSlackPostUpdatesToThreadWebhookURL(t *testing.T) { } } -func TestSlackUpdateMessageWithoutAPIURL(t *testing.T) { +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("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-app-token", err) - } - if err.Error() != "update_message 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", "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error()) + if err != nil { + t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-update-message-and-app-token.yml", err) } } -func TestSlackPostUpdatesToThreadWithoutAPIURL(t *testing.T) { +func TestSlackPostUpdatesToThreadWithAppToken(t *testing.T) { _, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-app-token.yml") - if err == nil { - t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-app-token", err) + if err != nil { + t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-app-token.yml", 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 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) } } diff --git a/config/testdata/conf.slack-update-message-and-api-url-file.yml b/config/testdata/conf.slack-update-message-and-api-url-file.yml new file mode 100644 index 0000000000..293317514f --- /dev/null +++ b/config/testdata/conf.slack-update-message-and-api-url-file.yml @@ -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 diff --git a/notify/slack/config.go b/notify/slack/config.go index 98d7e4c212..d0d47fde0e 100644 --- a/notify/slack/config.go +++ b/notify/slack/config.go @@ -199,7 +199,9 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { } // Validate checks that the Slack configuration endpoints and credentials are -// mutually consistent and that message-updating options use the bot-token flow. +// 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") @@ -211,13 +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 == nil || 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") - } - - if c.PostUpdatesToThread && (c.APIURL == nil || c.APIURL.String() != "https://slack.com/api/chat.postMessage") { - 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") - } - return nil } From e692bac8dd6679a0b1c3265154b4555e79586227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20GLON?= Date: Fri, 18 Sep 2026 14:17:14 +0200 Subject: [PATCH 4/5] notify/slack: skip the thread copy on repeat_interval notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When update_message and post_updates_to_thread are combined, a notification triggered only by repeat_interval elapsing edited the initial message and also posted a full copy of it in the thread. The edited channel message already carries the current state, so the thread reply only added noise on every repeat. Such notifications now only edit the initial message. The notification reason is already available on the context from the dedup stage. When post_updates_to_thread is used without update_message, repeats still post a thread reply, otherwise Slack would receive nothing at all. Behaviour suggested by @cxdy in #5577. Signed-off-by: Sébastien GLON Co-Authored-By: Claude Opus 5 (1M context) --- docs/configuration.md | 8 +++++-- notify/slack/slack.go | 13 +++++++++-- notify/slack/slack_test.go | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e7501d4896..9e8b15a923 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1764,8 +1764,12 @@ fields: # 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. -# Webhook URLs do not support threads. +# 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: | default = false ] ``` diff --git a/notify/slack/slack.go b/notify/slack/slack.go index 467c0c92e8..fd88a3e3e5 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -214,8 +214,10 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify } // 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. - if initialMessageSent && n.conf.UpdateMessage && n.conf.PostUpdatesToThread { + // 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 @@ -226,6 +228,13 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify return notify.Success() } +// 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 { diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 8fb326bad8..78cef7a75f 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -480,6 +480,54 @@ func TestSlackPostUpdatesToThread(t *testing.T) { require.Equal(t, "111.222", threadTs) }) + t.Run("repeat_interval notification only updates the message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonRepeatIntervalElapsed) + require.NoError(t, notifier.Notify(ctx).Err()) + + // The updated channel message already carries the current state, so no + // copy of it is added to the thread. + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + }) + + t.Run("repeat_interval notification still posts to thread without update_message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonRepeatIntervalElapsed) + require.NoError(t, notifier.Notify(ctx).Err()) + + // Without update_message nothing else carries the notification. + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.Equal(t, "111.222", captured[0].body["thread_ts"]) + }) + + t.Run("state change notification posts to thread after repeat_interval", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonNewAlertsInGroup) + require.NoError(t, notifier.Notify(ctx).Err()) + + require.Len(t, captured, 2) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[1].body["thread_ts"]) + }) + t.Run("subsequent notification posts only to thread without update_message", func(t *testing.T) { var captured []capturedRequest notifier := newTestNotifier(t, &SlackConfig{PostUpdatesToThread: true}, &captured, "999.999") From e4c36ef33482eda2674802d53998b805608c81e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20GLON?= Date: Fri, 18 Sep 2026 14:17:24 +0200 Subject: [PATCH 5/5] notify/slack: reject webhook api_url_file at notification time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat.postMessage requirement of update_message and post_updates_to_thread is validated during config resolution, but an api_url_file cannot be checked there: its content is only read when a notification is sent, so that a rotated value is picked up without a reload. A file holding a hooks.slack.com URL therefore reached the notifier, which sent ts or thread_ts to an incoming webhook that returns no message identifiers, silently doing nothing useful. The resolved URL is now checked once the file has been read, with the same error as config validation. Signed-off-by: Sébastien GLON Co-Authored-By: Claude Opus 5 (1M context) --- notify/slack/slack.go | 26 ++++++++++++++++++++ notify/slack/slack_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/notify/slack/slack.go b/notify/slack/slack.go index fd88a3e3e5..6151690daf 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -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...) @@ -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() @@ -228,6 +237,23 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify 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 { diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 78cef7a75f..7305fbc8a8 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -562,3 +562,53 @@ func TestSlackPostUpdatesToThread(t *testing.T) { require.NotContains(t, captured[0].body, "thread_ts") }) } + +func TestSlackRejectsWebhookAPIURLFileAtNotifyTime(t *testing.T) { + f, err := os.CreateTemp("", "slack_test") + require.NoError(t, err, "creating temp file failed") + t.Cleanup(func() { os.Remove(f.Name()) }) + _, err = f.WriteString("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\n") + require.NoError(t, err, "writing to temp file failed") + + for _, tc := range []struct { + name string + conf SlackConfig + err string + }{ + { + name: "update_message", + conf: SlackConfig{UpdateMessage: true}, + err: "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", + }, + { + name: "post_updates_to_thread", + conf: SlackConfig{PostUpdatesToThread: true}, + err: "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", + }, + } { + t.Run(tc.name, func(t *testing.T) { + conf := tc.conf + conf.APIURLFile = f.Name() + conf.Channel = "#test-channel" + conf.HTTPConfig = &commoncfg.HTTPClientConfig{} + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + tmpl.ExternalURL, err = url.Parse("http://am") + require.NoError(t, err) + + notifier, err := New(&conf, tmpl, slog.New(slog.DiscardHandler)) + require.NoError(t, err) + + notifier.postJSONFunc = func(ctx context.Context, client *http.Client, reqURL string, body io.Reader) (*http.Response, error) { + t.Fatal("no request must be sent to a webhook URL") + return nil, nil + } + + ctx := notify.WithNflogStore(notify.WithGroupKey(context.Background(), "test-group-key"), nflog.NewStore(nil)) + verdict := notifier.Notify(ctx) + require.False(t, verdict.ShouldRetry()) + require.EqualError(t, verdict.Err(), tc.err) + }) + } +}