Skip to content

Commit 8eb198f

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 766d019 commit 8eb198f

16 files changed

Lines changed: 1683 additions & 82 deletions

config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
412412
if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 {
413413
return errors.New("no Slack API URL nor App token set either inline or in a file")
414414
}
415+
if err := sc.ValidateMessageStrategy(); err != nil {
416+
return err
417+
}
415418
if sc.HTTPConfig == nil {
416419
// we don't want to change the global http config when setting the receiver's http config, do we do a copy
417420
httpconfig := *c.Global.HTTPConfig

config/config_test.go

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

1075-
func TestSlackUpdateMessageWebhookURL(t *testing.T) {
1076-
_, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml")
1075+
func TestSlackMessageStrategyWithWrongAPIURL(t *testing.T) {
1076+
t.Parallel()
1077+
1078+
tests := []struct {
1079+
name string
1080+
file string
1081+
strategy string
1082+
}{
1083+
{
1084+
name: "update strategy with webhook URL",
1085+
file: "testdata/conf.slack-update-message-and-webhook.yml",
1086+
strategy: `update`,
1087+
},
1088+
{
1089+
name: "update strategy with webhook URL (new field)",
1090+
file: "testdata/conf.slack-update-message-and-webhook-with-new-field.yml",
1091+
strategy: `update`,
1092+
},
1093+
{
1094+
name: "thread strategy with webhook URL",
1095+
file: "testdata/conf.slack-thread-message-and-webhook.yml",
1096+
strategy: `thread`,
1097+
},
1098+
}
1099+
1100+
for _, tt := range tests {
1101+
tt := tt
1102+
t.Run(tt.name, func(t *testing.T) {
1103+
t.Parallel()
1104+
1105+
_, err := LoadFile(tt.file)
1106+
if err == nil {
1107+
t.Fatal("Expected an error")
1108+
}
1109+
expectedErrMsg := fmt.Sprintf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", tt.strategy)
1110+
if err.Error() != expectedErrMsg {
1111+
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
1112+
}
1113+
})
1114+
}
1115+
}
1116+
1117+
func TestSlackMessageStrategyWithGlobalAPIURL(t *testing.T) {
1118+
t.Parallel()
1119+
1120+
_, err := LoadFile("testdata/conf.slack-thread-strategy-with-global-api-url.yml")
1121+
if err != nil {
1122+
t.Fatalf("Expected no error when message_strategy uses global slack_api_url, got: %s", err)
1123+
}
1124+
}
1125+
1126+
func TestSlackMessageStrategyWithoutAPIURL(t *testing.T) {
1127+
t.Parallel()
1128+
1129+
_, err := LoadFile("testdata/conf.slack-thread-strategy-without-api-url.yml")
10771130
if err == nil {
1078-
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err)
1131+
t.Fatal("Expected an error when message_strategy: thread has no api_url at any level")
1132+
}
1133+
expectedErrMsg := `no Slack API URL nor App token set either inline or in a file`
1134+
if err.Error() != expectedErrMsg {
1135+
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
1136+
}
1137+
}
1138+
1139+
func TestSlackThreadedOptionsValidation(t *testing.T) {
1140+
t.Parallel()
1141+
1142+
tests := []struct {
1143+
name string
1144+
file string
1145+
expectedErrMsg string
1146+
}{
1147+
{
1148+
name: "threaded options without thread strategy (resolve emoji)",
1149+
file: "testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml",
1150+
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
1151+
},
1152+
{
1153+
name: "threaded options without thread strategy (update parent)",
1154+
file: "testdata/conf.slack-thread-update-parent-without-thread-message.yml",
1155+
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
1156+
},
1157+
{
1158+
name: "resolve color without summary header",
1159+
file: "testdata/conf.slack-resolve-color-without-summary-header.yml",
1160+
expectedErrMsg: `threaded_options.summary_header requires use_summary_header to be enabled`,
1161+
},
10791162
}
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())
1163+
1164+
for _, tt := range tests {
1165+
tt := tt
1166+
t.Run(tt.name, func(t *testing.T) {
1167+
t.Parallel()
1168+
1169+
_, err := LoadFile(tt.file)
1170+
if err == nil {
1171+
t.Fatal("Expected an error")
1172+
}
1173+
if err.Error() != tt.expectedErrMsg {
1174+
t.Errorf("Expected: %s\nGot: %s", tt.expectedErrMsg, err.Error())
1175+
}
1176+
})
10821177
}
10831178
}
10841179

config/notifiers.go

Lines changed: 116 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,77 @@ 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+
// Deprecated: remove this block when update_message is deleted.
634+
if c.UpdateMessage {
635+
if c.MessageStrategy != "" && c.MessageStrategy != SlackMessageStrategyUpdate {
636+
return fmt.Errorf("update_message: true is incompatible with message_strategy %q; omit message_strategy or set message_strategy: \"update\"", c.MessageStrategy)
637+
}
638+
if c.MessageStrategy == "" {
639+
c.MessageStrategy = SlackMessageStrategyUpdate
640+
}
641+
}
642+
if c.MessageStrategy == "" {
643+
c.MessageStrategy = SlackMessageStrategyNew
644+
}
645+
646+
if c.MessageStrategy != SlackMessageStrategyNew &&
647+
c.MessageStrategy != SlackMessageStrategyUpdate &&
648+
c.MessageStrategy != SlackMessageStrategyThread {
649+
return fmt.Errorf("unknown message_strategy %q; must be \"new\", \"update\", or \"thread\"", c.MessageStrategy)
650+
}
651+
652+
if c.ThreadedOptions != nil {
653+
if c.MessageStrategy != SlackMessageStrategyThread {
654+
return errors.New("threaded_options requires message_strategy to be \"thread\"")
655+
}
656+
if c.ThreadedOptions.UseSummaryHeader != nil && !*c.ThreadedOptions.UseSummaryHeader && c.ThreadedOptions.SummaryHeader != nil {
657+
return errors.New("threaded_options.summary_header requires use_summary_header to be enabled")
658+
}
587659
}
588660

589661
return nil
590662
}
591663

664+
// ValidateMessageStrategy checks that the resolved api_url (after global defaults
665+
// have been applied) satisfies the requirements for update/thread strategies.
666+
func (c *SlackConfig) ValidateMessageStrategy() error {
667+
switch c.MessageStrategy {
668+
case SlackMessageStrategyUpdate, SlackMessageStrategyThread:
669+
if c.APIURL == nil && c.APIURLFile == "" {
670+
return fmt.Errorf("message_strategy %q requires api_url or api_url_file", c.MessageStrategy)
671+
}
672+
if c.APIURL != nil && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
673+
return fmt.Errorf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", c.MessageStrategy)
674+
}
675+
}
676+
return nil
677+
}
678+
679+
// UseSummaryHeaderInThread returns true when the thread parent should be a lightweight
680+
// auto-updated summary. Returns true by default (nil or explicit true).
681+
func (c *SlackConfig) UseSummaryHeaderInThread() bool {
682+
if c.ThreadedOptions == nil || c.ThreadedOptions.UseSummaryHeader == nil {
683+
return true
684+
}
685+
return *c.ThreadedOptions.UseSummaryHeader
686+
}
687+
688+
// HasStrategyThatUpdatesParent reports whether message_strategy uses nflog to tie
689+
// multiple notifications to one Slack message or thread.
690+
func (c *SlackConfig) HasStrategyThatUpdatesParent() bool {
691+
return c.HasUpdateStrategy() || c.HasThreadStrategy()
692+
}
693+
694+
// HasUpdateStrategy is true when message_strategy is "update" (chat.update in place).
695+
func (c *SlackConfig) HasUpdateStrategy() bool {
696+
return c.MessageStrategy == SlackMessageStrategyUpdate
697+
}
698+
699+
// HasThreadStrategy is true when message_strategy is "thread" (threaded replies).
700+
func (c *SlackConfig) HasThreadStrategy() bool {
701+
return c.MessageStrategy == SlackMessageStrategyThread
702+
}
703+
592704
// IncidentioConfig configures notifications via incident.io.
593705
type IncidentioConfig struct {
594706
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
global:
2+
slack_api_url: 'https://slack.com/api/chat.postMessage'
3+
route:
4+
receiver: 'slack-notifications'
5+
group_by: [alertname]
6+
receivers:
7+
- name: 'slack-notifications'
8+
slack_configs:
9+
- channel: '#alerts'
10+
text: 'test'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-test-token'
14+
message_strategy: thread
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts'
8+
text: 'test'
9+
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
# use global
8+
- channel: '#alerts1'
9+
text: 'test'
10+
send_resolved: true
11+
# trying to use webhook urls with update_message
12+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
13+
message_strategy: update

0 commit comments

Comments
 (0)