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 config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type Config struct {

SigningSecret string `password:"true" info:"Signing secret to verify requests from slack."`
InteractiveMessages bool `info:"Enable interactive messages (e.g. buttons)."`
IncludeDetails bool `public:"true" info:"Include alert details in Slack notifications with expandable view."`
}

Twilio struct {
Expand Down
21 changes: 21 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,24 @@ func TestConfig_Validate(t *testing.T) {
assert.Error(t, cfg.Validate(), "language must be a valid string")
})
}

func TestSlackIncludeDetails(t *testing.T) {
t.Run("Default value", func(t *testing.T) {
var cfg Config
assert.False(t, cfg.Slack.IncludeDetails, "IncludeDetails should default to false")
})

t.Run("Enabled configuration", func(t *testing.T) {
var cfg Config
cfg.Slack.IncludeDetails = true
assert.True(t, cfg.Slack.IncludeDetails, "IncludeDetails should be configurable to true")
assert.NoError(t, cfg.Validate(), "config with IncludeDetails enabled should validate")
})

t.Run("Disabled configuration", func(t *testing.T) {
var cfg Config
cfg.Slack.IncludeDetails = false
assert.False(t, cfg.Slack.IncludeDetails, "IncludeDetails should be configurable to false")
assert.NoError(t, cfg.Validate(), "config with IncludeDetails disabled should validate")
})
}
8 changes: 8 additions & 0 deletions graphql2/mapconfig.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 62 additions & 12 deletions notification/slack/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,34 +369,85 @@ const (
alertCloseActionID = "action_alert_close"
alertAckActionID = "action_alert_ack"
linkActActionID = "action_link_account"
showDetailsActionID = "action_show_details"
)

// alertMsgOption will return the slack.MsgOption for an alert-type message (e.g., notification or status update).
func alertMsgOption(ctx context.Context, callbackID string, id int, summary, logEntry string, state notification.AlertState) slack.MsgOption {
func alertMsgOption(ctx context.Context, callbackID string, id int, summary, details, logEntry string, state notification.AlertState) slack.MsgOption {
blocks := []slack.Block{
slack.NewSectionBlock(
slack.NewTextBlockObject("mrkdwn", alertLink(ctx, id, summary), false, false), nil, nil),
}

cfg := config.FromContext(ctx)

// Add details as a collapsible context if present and enabled in config
if details != "" && cfg.Slack.IncludeDetails {
// Truncate details for preview - show first line or first 150 characters
detailsLines := strings.Split(details, "\n")
detailsPreview := detailsLines[0]

if len(detailsPreview) > 150 {
detailsPreview = detailsPreview[:150] + "..."
}

hasMore := len(detailsLines) > 1 || len(details) > 150

if hasMore {
// Show preview with "Show more" option
blocks = append(blocks,
slack.NewContextBlock("details_preview",
slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Details:* %s", detailsPreview), false, false),
slack.NewTextBlockObject("mrkdwn", "_Click 'Show Details' to expand_", false, false),
),
)
} else {
// Show full details if short enough
blocks = append(blocks,
slack.NewContextBlock("details_full",
slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Details:* %s", details), false, false),
),
)
}
}

var color string
var actions []slack.Block
switch state {
case notification.AlertStateAcknowledged:
color = colorAcked
actionButtons := []slack.BlockElement{
slack.NewButtonBlockElement(alertCloseActionID, callbackID, slack.NewTextBlockObject("plain_text", "Close", false, false)),
}
// Add "Show Details" button if details exist and are long and config allows details
if details != "" && cfg.Slack.IncludeDetails && (len(strings.Split(details, "\n")) > 1 || len(details) > 150) {
// Create a details payload with alert info
detailsPayload := fmt.Sprintf(`{"type":"show_details","alert_id":%d,"summary":%q,"details":%q,"callback_id":%q}`,
id, summary, details, callbackID)
actionButtons = append(actionButtons,
slack.NewButtonBlockElement(showDetailsActionID, detailsPayload, slack.NewTextBlockObject("plain_text", "Show Details", false, false)))
}
actions = []slack.Block{
slack.NewDividerBlock(),
slack.NewActionBlock(alertResponseBlockID,
slack.NewButtonBlockElement(alertCloseActionID, callbackID, slack.NewTextBlockObject("plain_text", "Close", false, false)),
),
slack.NewActionBlock(alertResponseBlockID, actionButtons...),
}
case notification.AlertStateUnacknowledged:
color = colorUnacked
actionButtons := []slack.BlockElement{
slack.NewButtonBlockElement(alertAckActionID, callbackID, slack.NewTextBlockObject("plain_text", "Acknowledge", false, false)),
slack.NewButtonBlockElement(alertCloseActionID, callbackID, slack.NewTextBlockObject("plain_text", "Close", false, false)),
}
// Add "Show Details" button if details exist and are long and config allows details
if details != "" && cfg.Slack.IncludeDetails && (len(strings.Split(details, "\n")) > 1 || len(details) > 150) {
// Create a details payload with alert info
detailsPayload := fmt.Sprintf(`{"type":"show_details","alert_id":%d,"summary":%q,"details":%q,"callback_id":%q}`,
id, summary, details, callbackID)
actionButtons = append(actionButtons,
slack.NewButtonBlockElement(showDetailsActionID, detailsPayload, slack.NewTextBlockObject("plain_text", "Show Details", false, false)))
}
actions = []slack.Block{
slack.NewDividerBlock(),
slack.NewActionBlock(alertResponseBlockID,
slack.NewButtonBlockElement(alertAckActionID, callbackID, slack.NewTextBlockObject("plain_text", "Acknowledge", false, false)),
slack.NewButtonBlockElement(alertCloseActionID, callbackID, slack.NewTextBlockObject("plain_text", "Close", false, false)),
),
slack.NewActionBlock(alertResponseBlockID, actionButtons...),
}
case notification.AlertStateClosed:
color = colorClosed
Expand All @@ -405,7 +456,6 @@ func alertMsgOption(ctx context.Context, callbackID string, id int, summary, log
blocks = append(blocks,
slack.NewContextBlock("", slack.NewTextBlockObject("plain_text", logEntry, false, false)),
)
cfg := config.FromContext(ctx)
if len(actions) > 0 && cfg.Slack.InteractiveMessages {
blocks = append(blocks, actions...)
}
Expand Down Expand Up @@ -440,6 +490,7 @@ func (s *ChannelSender) SendMessage(ctx context.Context, msg notification.Messag

var opts []slack.MsgOption
var isUpdate bool
var externalID string
channelID := msg.DestArg(FieldSlackChannelID)
if msg.DestType() == DestTypeSlackDirectMessage {
// DMs are sent to the user ID, not the channel ID.
Expand All @@ -465,14 +516,14 @@ func (s *ChannelSender) SendMessage(ctx context.Context, msg notification.Messag
break
}

opts = append(opts, alertMsgOption(ctx, t.MsgID(), t.AlertID, t.Summary, "Unacknowledged", notification.AlertStateUnacknowledged))
opts = append(opts, alertMsgOption(ctx, t.MsgID(), t.AlertID, t.Summary, t.Details, "Unacknowledged", notification.AlertStateUnacknowledged))
case notification.AlertStatus:
isUpdate = true
var ts string
channelID, ts = chanTS(channelID, t.OriginalStatus.ProviderMessageID.ExternalID)
opts = append(opts,
slack.MsgOptionUpdate(ts),
alertMsgOption(ctx, t.OriginalStatus.ID, t.AlertID, t.Summary, t.LogEntry, t.NewAlertState),
alertMsgOption(ctx, t.OriginalStatus.ID, t.AlertID, t.Summary, "", t.LogEntry, t.NewAlertState),
)
case notification.AlertBundle:
opts = append(opts, slack.MsgOptionText(
Expand All @@ -486,7 +537,6 @@ func (s *ChannelSender) SendMessage(ctx context.Context, msg notification.Messag
return nil, errors.Errorf("unsupported message type: %T", t)
}

var externalID string
err := s.withClient(ctx, func(c *slack.Client) error {
msgChan, msgTS, err := c.PostMessageContext(ctx, channelID, opts...)
if err != nil {
Expand Down
69 changes: 69 additions & 0 deletions notification/slack/channel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/target/goalert/config"
"github.com/target/goalert/notification"
)

func TestChannelSender_LoadChannels(t *testing.T) {
Expand Down Expand Up @@ -65,3 +66,71 @@ func TestChannelSender_LoadChannels(t *testing.T) {
{ID: "C5", Name: "#channel5", TeamID: "team_1"},
}, ch)
}

// Test cases for the Slack details feature configuration
func TestAlertMsgOption_ConfigOption(t *testing.T) {
testCases := []struct {
name string
includeDetails bool
details string
expectDetails bool
expectShowBtn bool
}{
{
name: "Feature disabled with long details",
includeDetails: false,
details: "This is a very long alert details section that exceeds the 150 character limit and should be collapsed by default with a Show Details button to expand it when needed.",
expectDetails: false,
expectShowBtn: false,
},
{
name: "Feature enabled with long details",
includeDetails: true,
details: "This is a very long alert details section that exceeds the 150 character limit and should be collapsed by default with a Show Details button to expand it when needed.",
expectDetails: true,
expectShowBtn: true,
},
{
name: "Feature enabled with short details",
includeDetails: true,
details: "Short details",
expectDetails: true,
expectShowBtn: false,
},
{
name: "Feature enabled with multiline details",
includeDetails: true,
details: "Line 1\nLine 2\nLine 3",
expectDetails: true,
expectShowBtn: true,
},
{
name: "Feature enabled with empty details",
includeDetails: true,
details: "",
expectDetails: false,
expectShowBtn: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
var cfg config.Config
cfg.Slack.IncludeDetails = tc.includeDetails
ctx = cfg.Context(ctx)

msgOpt := alertMsgOption(ctx, "alert:123:unacknowledged", 123, "Test Alert", tc.details, "2023-11-17 10:00:00", notification.AlertStateUnacknowledged)

// Verify the message option was created
require.NotNil(t, msgOpt)

// Create a simple test to verify the function works
// We'll just ensure no panic occurs and the option is valid
assert.NotPanics(t, func() {
// This tests that the msgOpt function can be called without panicking
_ = msgOpt
})
})
}
}
Loading
Loading