Skip to content
Closed
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
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
}
sc.APIURL = (*amcommoncfg.SecretURL)(sc.AppURL)
}
if err := sc.validateMessageAPIURL(); err != nil {
return err
}
}
for _, poc := range rcv.PushoverConfigs {
if poc == nil {
Expand Down
92 changes: 92 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,98 @@ func TestSlackUpdateMessageWebhookURL(t *testing.T) {
}
}

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

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

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

func TestSlackThreadRepliesAppTokenIgnoresGlobalAPIURLFile(t *testing.T) {
urlFile := t.TempDir() + "/api_url"
if err := os.WriteFile(urlFile, []byte("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := fmt.Sprintf(`
global:
slack_api_url_file: %q
route:
receiver: slack
receivers:
- name: slack
slack_configs:
- channel: '#alerts'
app_token: 'xoxb-some-token'
thread_replies: true
`, urlFile)
if _, err := Load(cfg); err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
}

func TestSlackThreadRepliesWithAPIURLFile(t *testing.T) {
urlFile := t.TempDir() + "/api_url"
if err := os.WriteFile(urlFile, []byte("https://slack.com/api/chat.postMessage\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := fmt.Sprintf(`
route:
receiver: slack
receivers:
- name: slack
slack_configs:
- channel: '#alerts'
api_url_file: %q
thread_replies: true
`, urlFile)
if _, err := Load(cfg); err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
}

func TestSlackThreadRepliesAPIURLFileWebhook(t *testing.T) {
urlFile := t.TempDir() + "/api_url"
if err := os.WriteFile(urlFile, []byte("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := fmt.Sprintf(`
route:
receiver: slack
receivers:
- name: slack
slack_configs:
- channel: '#alerts'
api_url_file: %q
thread_replies: true
`, urlFile)
_, err := Load(cfg)
if err == nil {
t.Fatal("Load() error = nil, want webhook rejected")
}
want := "thread_replies can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage"
if err.Error() != want {
t.Errorf("Expected: %s\nGot: %s", want, err.Error())
}
}

func TestSlackGlobalAppToken(t *testing.T) {
conf, err := LoadFile("testdata/conf.slack-default-app-token.yml")
if err != nil {
Expand Down
41 changes: 36 additions & 5 deletions config/notifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import (
"errors"
"fmt"
"net/textproto"
"os"
"regexp"
"slices"
"strings"
"time"

commoncfg "github.com/prometheus/common/config"
Expand Down Expand Up @@ -333,9 +335,15 @@ type SlackConfig struct {
Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"`

// UpdateMessage enables updating existing Slack messages instead of creating new ones.
// Requires bot token with chat:write scope. Webhook URLs do not support updates.

// Incoming webhooks cannot be used. Requires a Slack app with a bot token
// (chat:write) and api_url https://slack.com/api/chat.postMessage.
UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"`
// ThreadReplies posts follow-up notifications for an alert group as replies
// in the Slack thread of the group's first message. Incoming webhooks
// (hooks.slack.com) cannot be used; they do not return a message timestamp.
// Requires a Slack app with a bot token (chat:write) and
// api_url https://slack.com/api/chat.postMessage.
ThreadReplies bool `yaml:"thread_replies" json:"thread_replies,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 @@ -351,6 +359,10 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error {
return c.Validate()
}

// Validate checks that Slack credential fields are mutually exclusive. The
// chat.postMessage requirement of update_message and thread_replies is checked
// during global config resolution, after api_url is filled in 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 @@ -362,11 +374,30 @@ 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 nil
}

func (c *SlackConfig) validateMessageAPIURL() error {
if !c.UpdateMessage && !c.ThreadReplies {
return nil
}
apiURL := ""
if c.APIURL != nil {
apiURL = c.APIURL.String()
} else if len(c.APIURLFile) > 0 {
content, err := os.ReadFile(c.APIURLFile)
if err != nil {
return fmt.Errorf("reading api_url_file: %w", err)
}
apiURL = strings.TrimSpace(string(content))
}
if apiURL == "https://slack.com/api/chat.postMessage" {
return nil
}
if c.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")
}

return nil
return errors.New("thread_replies can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
}

// WechatConfig configures notifications via Wechat.
Expand Down
11 changes: 11 additions & 0 deletions config/testdata/conf.slack-thread-replies-and-app-token.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts1'
text: 'test'
send_resolved: true
app_token: 'xoxb-some-token'
thread_replies: true
11 changes: 11 additions & 0 deletions config/testdata/conf.slack-thread-replies-and-webhook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts1'
text: 'test'
send_resolved: true
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
thread_replies: 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
22 changes: 21 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1699,6 +1699,8 @@ If using an incoming webhook then `api_url` must be set to the URL of the incomi

If using Bot tokens then `api_url` must be set to [`https://slack.com/api/chat.postMessage`](https://api.slack.com/methods/chat.postMessage), the bot token must be set as the authorization credentials in `http_config`, and `channel` must contain either the name of the channel or Channel ID to send notifications to. If using the name of the channel the # is optional.

`update_message` and `thread_replies` do **not** work with incoming webhooks (`https://hooks.slack.com/services/...`). Incoming webhooks only return `ok` and never a message timestamp, so Alertmanager cannot edit a message or reply in its thread. Both options require a [Slack app](https://api.slack.com/authentication/basics) with a bot token (`chat:write`) and `api_url: https://slack.com/api/chat.postMessage`. Invite the bot into the channel.

The notification contains an [attachment](https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments/).

```yaml
Expand Down Expand Up @@ -1757,8 +1759,26 @@ fields:
[ timeout: <duration> | default = 0s ]

# Enables updating existing Slack messages instead of creating new ones on alert state change.
# Webhook URLs do not support updates.
# Incoming webhooks (https://hooks.slack.com/services/...) cannot be used.
# Requires a Slack app with a bot token and api_url https://slack.com/api/chat.postMessage.
[ update_message: <boolean> | default = false ]

# Post follow-up notifications for the same alert group as replies in the
# Slack thread of the group's first message. A later firing after the group
# has fully resolved starts a new thread.
#
# Incoming webhooks (https://hooks.slack.com/services/...) cannot be used.
# Slack does not return a message timestamp from a webhook, so there is
# nothing to thread onto. Requires a Slack app with a bot token (chat:write)
# and api_url or api_url_file equal to https://slack.com/api/chat.postMessage.
# Invite the bot into the destination channel.
#
# Together with update_message, state changes edit the first message and
# also add a reply. Repeat-interval notifications then only edit the first
# message, so the thread is not filled with copies of a message that is
# already current. thread_replies without update_message still posts a
# reply on repeats; otherwise the repeat would not appear in Slack at all.
[ thread_replies: <boolean> | default = false ]
```

#### `<action_config>` (Slack)
Expand Down
Loading
Loading