Skip to content
Draft
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
1 change: 1 addition & 0 deletions .cursor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
plans/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
/amtool
/data/
/vendor
.idea
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 {
return errors.New("no Slack API URL nor App token set either inline or in a file")
}
if err := sc.ValidateMessageStrategy(); err != nil {
return err
}
if sc.HTTPConfig == nil {
// we don't want to change the global http config when setting the receiver's http config, do we do a copy
httpconfig := *c.Global.HTTPConfig
Expand Down
103 changes: 98 additions & 5 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1072,13 +1072,106 @@ func TestSlackBothAppTokenAndAPIURL(t *testing.T) {
}
}

func TestSlackUpdateMessageWebhookURL(t *testing.T) {
_, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml")
func TestSlackMessageStrategyWithWrongAPIURL(t *testing.T) {
t.Parallel()

tests := []struct {
name string
file string
strategy string
}{
{
name: "update strategy with webhook URL",
file: "testdata/conf.slack-update-message-and-webhook.yml",
strategy: `update`,
},
{
name: "update strategy with webhook URL (new field)",
file: "testdata/conf.slack-update-message-and-webhook-with-new-field.yml",
strategy: `update`,
},
{
name: "thread strategy with webhook URL",
file: "testdata/conf.slack-thread-message-and-webhook.yml",
strategy: `thread`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, err := LoadFile(tt.file)
if err == nil {
t.Fatal("Expected an error")
}
expectedErrMsg := fmt.Sprintf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", tt.strategy)
if err.Error() != expectedErrMsg {
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
}
})
}
}

func TestSlackMessageStrategyWithGlobalAPIURL(t *testing.T) {
t.Parallel()

_, err := LoadFile("testdata/conf.slack-thread-strategy-with-global-api-url.yml")
if err != nil {
t.Fatalf("Expected no error when message_strategy uses global slack_api_url, got: %s", err)
}
}

func TestSlackMessageStrategyWithoutAPIURL(t *testing.T) {
t.Parallel()

_, err := LoadFile("testdata/conf.slack-thread-strategy-without-api-url.yml")
if err == nil {
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err)
t.Fatal("Expected an error when message_strategy: thread has no api_url at any level")
}
expectedErrMsg := `no Slack API URL nor App token set either inline or in a file`
if err.Error() != expectedErrMsg {
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
}
}

func TestSlackThreadedOptionsValidation(t *testing.T) {
t.Parallel()

tests := []struct {
name string
file string
expectedErrMsg string
}{
{
name: "threaded options without thread strategy (resolve emoji)",
file: "testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml",
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
},
{
name: "threaded options without thread strategy (update parent)",
file: "testdata/conf.slack-thread-update-parent-without-thread-message.yml",
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
},
{
name: "resolve color without summary header",
file: "testdata/conf.slack-resolve-color-without-summary-header.yml",
expectedErrMsg: `threaded_options.summary_header requires use_summary_header to be enabled`,
},
}
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())

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, err := LoadFile(tt.file)
if err == nil {
t.Fatal("Expected an error")
}
if err.Error() != tt.expectedErrMsg {
t.Errorf("Expected: %s\nGot: %s", tt.expectedErrMsg, err.Error())
}
})
}
}

Expand Down
120 changes: 116 additions & 4 deletions config/notifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,45 @@ func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}

// SlackMessageStrategy controls how subsequent notifications for the same
// alert group are delivered to Slack.
type SlackMessageStrategy string

const (
// SlackMessageStrategyNew sends each notification as a separate message (default).
SlackMessageStrategyNew SlackMessageStrategy = "new"
// SlackMessageStrategyUpdate updates the original message in-place.
SlackMessageStrategyUpdate SlackMessageStrategy = "update"
// SlackMessageStrategyThread sends subsequent notifications as threaded replies.
SlackMessageStrategyThread SlackMessageStrategy = "thread"
)

// SlackThreadedOptions configures thread-specific behavior when message_strategy is "thread".
type SlackThreadedOptions struct {
// ResolveEmoji is the emoji name (without colons) to react with on the
// original thread message when all alerts in the group are resolved.
// Requires the bot token to have reactions:write scope.
ResolveEmoji string `yaml:"resolve_emoji,omitempty" json:"resolve_emoji,omitempty"`

// UseSummaryHeader controls whether the thread parent is a lightweight
// auto-updated summary (true, default) or the first actual alert message
// (false). When true, all alert content is posted as replies and the parent
// is continuously updated with the transition title and color.
UseSummaryHeader *bool `yaml:"use_summary_header,omitempty" json:"use_summary_header,omitempty"`

// SummaryHeader holds options for summary-header mode only (see UseSummaryHeader).
SummaryHeader *SlackThreadSummaryHeaderOptions `yaml:"summary_header,omitempty" json:"summary_header,omitempty"`
}

// SlackThreadSummaryHeaderOptions configures fields that only apply when
// message_strategy is "thread" and use_summary_header is true (the lightweight
// parent summary mode).
type SlackThreadSummaryHeaderOptions struct {
// ResolveColor overrides the parent summary attachment color when the alert
// group resolves. Supports Go templates.
ResolveColor string `yaml:"resolve_color,omitempty" json:"resolve_color,omitempty"`
}

// SlackConfig configures notifications via Slack.
type SlackConfig struct {
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
Expand Down Expand Up @@ -505,10 +544,19 @@ type SlackConfig struct {
MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"`
Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"`

// MessageStrategy controls how subsequent notifications for the same alert
// group are delivered: "new" (default), "update" (edit in-place), or "thread"
// (threaded replies). "update" and "thread" require a bot token.
MessageStrategy SlackMessageStrategy `yaml:"message_strategy,omitempty" json:"message_strategy,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.
// Deprecated: use message_strategy: update instead. If true, message_strategy must
// be unset or "update"; when message_strategy is unset it is treated as "update".
UpdateMessage bool `yaml:"update_message,omitempty" json:"update_message,omitempty"`

UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"`
// ThreadedOptions configures thread-specific behavior.
// Only valid when message_strategy is "thread".
ThreadedOptions *SlackThreadedOptions `yaml:"threaded_options,omitempty" json:"threaded_options,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 @@ -532,13 +580,77 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) 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")
// Deprecated: remove this block when update_message is deleted.
if c.UpdateMessage {
if c.MessageStrategy != "" && c.MessageStrategy != SlackMessageStrategyUpdate {
return fmt.Errorf("update_message: true is incompatible with message_strategy %q; omit message_strategy or set message_strategy: \"update\"", c.MessageStrategy)
}
if c.MessageStrategy == "" {
c.MessageStrategy = SlackMessageStrategyUpdate
}
}
if c.MessageStrategy == "" {
c.MessageStrategy = SlackMessageStrategyNew
}

if c.MessageStrategy != SlackMessageStrategyNew &&
c.MessageStrategy != SlackMessageStrategyUpdate &&
c.MessageStrategy != SlackMessageStrategyThread {
return fmt.Errorf("unknown message_strategy %q; must be \"new\", \"update\", or \"thread\"", c.MessageStrategy)
}

if c.ThreadedOptions != nil {
if c.MessageStrategy != SlackMessageStrategyThread {
return errors.New("threaded_options requires message_strategy to be \"thread\"")
}
if c.ThreadedOptions.UseSummaryHeader != nil && !*c.ThreadedOptions.UseSummaryHeader && c.ThreadedOptions.SummaryHeader != nil {
return errors.New("threaded_options.summary_header requires use_summary_header to be enabled")
}
}

return nil
}

// ValidateMessageStrategy checks that the resolved api_url (after global defaults
// have been applied) satisfies the requirements for update/thread strategies.
func (c *SlackConfig) ValidateMessageStrategy() error {
switch c.MessageStrategy {
case SlackMessageStrategyUpdate, SlackMessageStrategyThread:
if c.APIURL == nil && c.APIURLFile == "" {
return fmt.Errorf("message_strategy %q requires api_url or api_url_file", c.MessageStrategy)
}
if c.APIURL != nil && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
return fmt.Errorf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", c.MessageStrategy)
}
}
return nil
}

// UseSummaryHeaderInThread returns true when the thread parent should be a lightweight
// auto-updated summary. Returns true by default (nil or explicit true).
func (c *SlackConfig) UseSummaryHeaderInThread() bool {
if c.ThreadedOptions == nil || c.ThreadedOptions.UseSummaryHeader == nil {
return true
}
return *c.ThreadedOptions.UseSummaryHeader
}

// HasStrategyThatUpdatesParent reports whether message_strategy uses nflog to tie
// multiple notifications to one Slack message or thread.
func (c *SlackConfig) HasStrategyThatUpdatesParent() bool {
return c.HasUpdateStrategy() || c.HasThreadStrategy()
}

// HasUpdateStrategy is true when message_strategy is "update" (chat.update in place).
func (c *SlackConfig) HasUpdateStrategy() bool {
return c.MessageStrategy == SlackMessageStrategyUpdate
}

// HasThreadStrategy is true when message_strategy is "thread" (threaded replies).
func (c *SlackConfig) HasThreadStrategy() bool {
return c.MessageStrategy == SlackMessageStrategyThread
}

// IncidentioConfig configures notifications via incident.io.
type IncidentioConfig struct {
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts1'
text: 'test'
send_resolved: true
api_url: 'https://slack.com/api/chat.postMessage'
http_config:
authorization:
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
message_strategy: thread
threaded_options:
use_summary_header: false
summary_header:
resolve_color: '#AAAAAA'
11 changes: 11 additions & 0 deletions config/testdata/conf.slack-thread-message-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'
message_strategy: thread
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts1'
text: 'test'
send_resolved: true
api_url: 'https://slack.com/api/chat.postMessage'
http_config:
authorization:
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
threaded_options:
resolve_emoji: 'white_check_mark'
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
global:
slack_api_url: 'https://slack.com/api/chat.postMessage'
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
text: 'test'
http_config:
authorization:
credentials: 'xoxb-test-token'
message_strategy: thread
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
text: 'test'
message_strategy: thread
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
route:
receiver: 'slack-notifications'
group_by: [alertname]
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts1'
text: 'test'
send_resolved: true
api_url: 'https://slack.com/api/chat.postMessage'
http_config:
authorization:
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
threaded_options:
resolve_emoji: 'white_check_mark'
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 update_message
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
message_strategy: update
Loading