Skip to content

Commit 7ea82dd

Browse files
committed
feat: add threaded messages as an slack option
Signed-off-by: Santiago Fernández Núñez <santiago.nunez@nubank.com.br> Made-with: Cursor
1 parent 7976328 commit 7ea82dd

12 files changed

Lines changed: 1570 additions & 82 deletions

config/config_test.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,13 +1072,58 @@ func TestSlackBothAppTokenAndAPIURL(t *testing.T) {
10721072
}
10731073
}
10741074

1075-
func TestSlackUpdateMessageWebhookURL(t *testing.T) {
1075+
func TestSlackUpdateStrategyWebhookURL(t *testing.T) {
10761076
_, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml")
10771077
if err == nil {
1078-
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err)
1078+
t.Fatal("Expected an error")
10791079
}
1080-
if err.Error() != "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" {
1081-
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())
1080+
expected := `message_strategy "update" requires a bot token; api_url must be https://slack.com/api/chat.postMessage`
1081+
if err.Error() != expected {
1082+
t.Errorf("Expected: %s\nGot: %s", expected, err.Error())
1083+
}
1084+
}
1085+
1086+
func TestSlackThreadStrategyWebhookURL(t *testing.T) {
1087+
_, err := LoadFile("testdata/conf.slack-thread-message-and-webhook.yml")
1088+
if err == nil {
1089+
t.Fatal("Expected an error")
1090+
}
1091+
expected := `message_strategy "thread" requires a bot token; api_url must be https://slack.com/api/chat.postMessage`
1092+
if err.Error() != expected {
1093+
t.Errorf("Expected: %s\nGot: %s", expected, err.Error())
1094+
}
1095+
}
1096+
1097+
func TestSlackThreadedOptionsWithoutThreadStrategy(t *testing.T) {
1098+
_, err := LoadFile("testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml")
1099+
if err == nil {
1100+
t.Fatal("Expected an error")
1101+
}
1102+
expected := `threaded_options requires message_strategy to be "thread"`
1103+
if err.Error() != expected {
1104+
t.Errorf("Expected: %s\nGot: %s", expected, err.Error())
1105+
}
1106+
}
1107+
1108+
func TestSlackThreadedOptionsUpdateParentWithoutThreadStrategy(t *testing.T) {
1109+
_, err := LoadFile("testdata/conf.slack-thread-update-parent-without-thread-message.yml")
1110+
if err == nil {
1111+
t.Fatal("Expected an error")
1112+
}
1113+
expected := `threaded_options requires message_strategy to be "thread"`
1114+
if err.Error() != expected {
1115+
t.Errorf("Expected: %s\nGot: %s", expected, err.Error())
1116+
}
1117+
}
1118+
1119+
func TestSlackResolveColorWithoutSummaryHeader(t *testing.T) {
1120+
_, err := LoadFile("testdata/conf.slack-resolve-color-without-summary-header.yml")
1121+
if err == nil {
1122+
t.Fatal("Expected an error")
1123+
}
1124+
expected := `threaded_options.summary_header requires use_summary_header to be enabled`
1125+
if err.Error() != expected {
1126+
t.Errorf("Expected: %s\nGot: %s", expected, err.Error())
10821127
}
10831128
}
10841129

config/notifiers.go

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,45 @@ func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error {
520520
return nil
521521
}
522522

523+
// SlackMessageStrategy controls how subsequent notifications for the same
524+
// alert group are delivered to Slack.
525+
type SlackMessageStrategy string
526+
527+
const (
528+
// SlackMessageStrategyNew sends each notification as a separate message (default).
529+
SlackMessageStrategyNew SlackMessageStrategy = "new"
530+
// SlackMessageStrategyUpdate updates the original message in-place.
531+
SlackMessageStrategyUpdate SlackMessageStrategy = "update"
532+
// SlackMessageStrategyThread sends subsequent notifications as threaded replies.
533+
SlackMessageStrategyThread SlackMessageStrategy = "thread"
534+
)
535+
536+
// SlackThreadedOptions configures thread-specific behavior when message_strategy is "thread".
537+
type SlackThreadedOptions struct {
538+
// ResolveEmoji is the emoji name (without colons) to react with on the
539+
// original thread message when all alerts in the group are resolved.
540+
// Requires the bot token to have reactions:write scope.
541+
ResolveEmoji string `yaml:"resolve_emoji,omitempty" json:"resolve_emoji,omitempty"`
542+
543+
// UseSummaryHeader controls whether the thread parent is a lightweight
544+
// auto-updated summary (true, default) or the first actual alert message
545+
// (false). When true, all alert content is posted as replies and the parent
546+
// is continuously updated with the transition title and color.
547+
UseSummaryHeader *bool `yaml:"use_summary_header,omitempty" json:"use_summary_header,omitempty"`
548+
549+
// SummaryHeader holds options for summary-header mode only (see UseSummaryHeader).
550+
SummaryHeader *SlackThreadSummaryHeaderOptions `yaml:"summary_header,omitempty" json:"summary_header,omitempty"`
551+
}
552+
553+
// SlackThreadSummaryHeaderOptions configures fields that only apply when
554+
// message_strategy is "thread" and use_summary_header is true (the lightweight
555+
// parent summary mode).
556+
type SlackThreadSummaryHeaderOptions struct {
557+
// ResolveColor overrides the parent summary attachment color when the alert
558+
// group resolves. Supports Go templates.
559+
ResolveColor string `yaml:"resolve_color,omitempty" json:"resolve_color,omitempty"`
560+
}
561+
523562
// SlackConfig configures notifications via Slack.
524563
type SlackConfig struct {
525564
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
@@ -555,10 +594,19 @@ type SlackConfig struct {
555594
MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"`
556595
Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"`
557596

597+
// MessageStrategy controls how subsequent notifications for the same alert
598+
// group are delivered: "new" (default), "update" (edit in-place), or "thread"
599+
// (threaded replies). "update" and "thread" require a bot token.
600+
MessageStrategy SlackMessageStrategy `yaml:"message_strategy,omitempty" json:"message_strategy,omitempty"`
601+
558602
// UpdateMessage enables updating existing Slack messages instead of creating new ones.
559-
// Requires bot token with chat:write scope. Webhook URLs do not support updates.
603+
// Deprecated: use message_strategy: update instead. If true, message_strategy must
604+
// be unset or "update"; when message_strategy is unset it is treated as "update".
605+
UpdateMessage bool `yaml:"update_message,omitempty" json:"update_message,omitempty"`
560606

561-
UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"`
607+
// ThreadedOptions configures thread-specific behavior.
608+
// Only valid when message_strategy is "thread".
609+
ThreadedOptions *SlackThreadedOptions `yaml:"threaded_options,omitempty" json:"threaded_options,omitempty"`
562610
// Timeout is the maximum time allowed to invoke the slack. Setting this to 0
563611
// does not impose a timeout.
564612
Timeout time.Duration `yaml:"timeout" json:"timeout"`
@@ -582,13 +630,69 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error {
582630
return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured")
583631
}
584632

585-
if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
586-
return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
633+
if c.UpdateMessage {
634+
if c.MessageStrategy != "" && c.MessageStrategy != SlackMessageStrategyUpdate {
635+
return fmt.Errorf("update_message: true is incompatible with message_strategy %q; omit message_strategy or set message_strategy: \"update\"", c.MessageStrategy)
636+
}
637+
if c.MessageStrategy == "" {
638+
c.MessageStrategy = SlackMessageStrategyUpdate
639+
}
640+
}
641+
if c.MessageStrategy == "" {
642+
c.MessageStrategy = SlackMessageStrategyNew
643+
}
644+
645+
switch c.MessageStrategy {
646+
case SlackMessageStrategyNew:
647+
// no extra requirements
648+
case SlackMessageStrategyUpdate, SlackMessageStrategyThread:
649+
if c.APIURL == nil && c.APIURLFile == "" {
650+
return fmt.Errorf("message_strategy %q requires api_url or api_url_file", c.MessageStrategy)
651+
}
652+
if c.APIURL != nil && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
653+
return fmt.Errorf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", c.MessageStrategy)
654+
}
655+
default:
656+
return fmt.Errorf("unknown message_strategy %q; must be \"new\", \"update\", or \"thread\"", c.MessageStrategy)
657+
}
658+
659+
if c.ThreadedOptions != nil {
660+
if c.MessageStrategy != SlackMessageStrategyThread {
661+
return errors.New("threaded_options requires message_strategy to be \"thread\"")
662+
}
663+
if c.ThreadedOptions.UseSummaryHeader != nil && !*c.ThreadedOptions.UseSummaryHeader && c.ThreadedOptions.SummaryHeader != nil {
664+
return errors.New("threaded_options.summary_header requires use_summary_header to be enabled")
665+
}
587666
}
588667

589668
return nil
590669
}
591670

671+
// UseSummaryHeaderInThread returns true when the thread parent should be a lightweight
672+
// auto-updated summary. Returns true by default (nil or explicit true).
673+
func (c *SlackConfig) UseSummaryHeaderInThread() bool {
674+
if c.ThreadedOptions == nil || c.ThreadedOptions.UseSummaryHeader == nil {
675+
return true
676+
}
677+
return *c.ThreadedOptions.UseSummaryHeader
678+
}
679+
680+
// HasStrategyThatUpdatesParent reports whether message_strategy uses nflog to tie
681+
// multiple notifications to one Slack message or thread.
682+
func (c *SlackConfig) HasStrategyThatUpdatesParent() bool {
683+
return c.HasUpdateStrategy() || c.HasThreadStrategy()
684+
}
685+
686+
// HasUpdateStrategy is true when message_strategy is "update" (chat.update in place).
687+
func (c *SlackConfig) HasUpdateStrategy() bool {
688+
return c.MessageStrategy == SlackMessageStrategyUpdate
689+
}
690+
691+
// HasThreadStrategy is true when message_strategy is "thread" (threaded replies).
692+
func (c *SlackConfig) HasThreadStrategy() bool {
693+
return c.MessageStrategy == SlackMessageStrategyThread
694+
}
695+
592696
// IncidentioConfig configures notifications via incident.io.
593697
type IncidentioConfig struct {
594698
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
message_strategy: thread
15+
threaded_options:
16+
use_summary_header: false
17+
summary_header:
18+
resolve_color: '#AAAAAA'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
11+
message_strategy: thread
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
threaded_options:
15+
resolve_emoji: 'white_check_mark'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
threaded_options:
15+
resolve_emoji: 'white_check_mark'

config/testdata/conf.slack-update-message-and-webhook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ receivers:
1010
send_resolved: true
1111
# trying to use webhook urls with update_message
1212
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
13-
update_message: true
13+
message_strategy: update

docs/configuration.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,9 +1662,42 @@ fields:
16621662
# NOTE: This will have no effect if set higher than the group_interval.
16631663
[ timeout: <duration> | default = 0s ]
16641664
1665-
# Enables updating existing Slack messages instead of creating new ones on alert state change.
1666-
# Webhook URLs do not support updates.
1667-
[ update_message: <boolean> | default = false ]
1665+
# Controls how subsequent notifications for the same alert group are delivered.
1666+
# "new" (default): each notification is a separate message.
1667+
# "update": subsequent notifications update the original message in-place.
1668+
# "thread": subsequent notifications are sent as threaded replies.
1669+
# "update" and "thread" require api_url set to https://slack.com/api/chat.postMessage.
1670+
[ message_strategy: <string> | default = "new" ]
1671+
1672+
# Limitation for "thread" (and "update"): thread metadata (for example the parent
1673+
# message timestamp and channel id) is written to the notification log only after
1674+
# the integration returns success for the entire notification attempt. If Slack
1675+
# accepts an earlier API call in the same attempt but a later call fails and retries
1676+
# are exhausted, the next flush may not see stored thread state and can open a new
1677+
# thread in the channel, leaving the previous thread without further updates.
1678+
1679+
# Options for threaded message behavior. Only valid when message_strategy is "thread".
1680+
threaded_options:
1681+
# When true (default), the thread parent is a lightweight auto-updated summary
1682+
# header and all alert content is posted as replies. When false, the first
1683+
# alert message IS the thread parent and subsequent alerts are threaded replies.
1684+
[ use_summary_header: <boolean> | default = true ]
1685+
1686+
# Emoji name (without colons) to react with on the parent thread message
1687+
# when all alerts in the group are resolved (e.g. "white_check_mark").
1688+
# Requires the bot token to have reactions:write scope.
1689+
[ resolve_emoji: <string> ]
1690+
1691+
# Options that only apply when use_summary_header is true (default).
1692+
[ summary_header: <summary_header_thread_config> ]
1693+
```
1694+
1695+
##### `<summary_header_thread_config>`
1696+
1697+
```yaml
1698+
# Overrides the parent summary attachment color when all alerts resolve.
1699+
# Supports Go templates.
1700+
[ resolve_color: <tmpl_string> ]
16681701
```
16691702

16701703
#### `<action_config>` (Slack)

0 commit comments

Comments
 (0)