Skip to content

Commit

Permalink
Add reactions to Slack messages (#1156)
Browse files Browse the repository at this point in the history
  • Loading branch information
mszostok authored Jul 20, 2023
1 parent 332a33b commit 2242de4
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 97 deletions.
89 changes: 50 additions & 39 deletions pkg/bot/cloudslack.go → pkg/bot/slack_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,21 @@ var _ Bot = &CloudSlack{}

// CloudSlack listens for user's message, execute commands and sends back the response.
type CloudSlack struct {
log logrus.FieldLogger
cfg config.CloudSlack
client *slack.Client
executorFactory ExecutorFactory
reporter cloudSlackAnalyticsReporter
commGroupName string
realNamesForID map[string]string
botMentionRegex *regexp.Regexp
botID string
channelsMutex sync.RWMutex
renderer *SlackRenderer
channels map[string]channelConfigByName
notifyMutex sync.Mutex
clusterName string
log logrus.FieldLogger
cfg config.CloudSlack
client *slack.Client
executorFactory ExecutorFactory
reporter cloudSlackAnalyticsReporter
commGroupName string
realNamesForID map[string]string
botMentionRegex *regexp.Regexp
botID string
channelsMutex sync.RWMutex
renderer *SlackRenderer
channels map[string]channelConfigByName
notifyMutex sync.Mutex
clusterName string
msgStatusTracker *SlackMessageStatusTracker
}

// cloudSlackAnalyticsReporter defines a reporter that collects analytics data.
Expand Down Expand Up @@ -91,18 +92,19 @@ func NewCloudSlack(log logrus.FieldLogger,
}

return &CloudSlack{
log: log,
cfg: cfg,
executorFactory: executorFactory,
reporter: reporter,
commGroupName: commGroupName,
botMentionRegex: botMentionRegex,
renderer: NewSlackRenderer(),
channels: channels,
client: client,
botID: cfg.BotID,
clusterName: clusterName,
realNamesForID: map[string]string{},
log: log,
cfg: cfg,
executorFactory: executorFactory,
reporter: reporter,
commGroupName: commGroupName,
botMentionRegex: botMentionRegex,
renderer: NewSlackRenderer(),
channels: channels,
client: client,
botID: cfg.BotID,
clusterName: clusterName,
realNamesForID: map[string]string{},
msgStatusTracker: NewSlackMessageStatusTracker(log, client),
}, nil
}

Expand Down Expand Up @@ -206,11 +208,12 @@ func (b *CloudSlack) start(ctx context.Context) error {
case *slackevents.AppMentionEvent:
b.log.Debugf("Got app mention %s", formatx.StructDumper().Sdump(innerEvent))
userName := b.getRealNameWithFallbackToUserID(ctx, ev.User)
msg := socketSlackMessage{
msg := slackMessage{
Text: ev.Text,
Channel: ev.Channel,
ThreadTimeStamp: ev.ThreadTimeStamp,
UserID: ev.User,
EventTimeStamp: ev.EventTimeStamp,
UserName: userName,
CommandOrigin: command.TypedOrigin,
}
Expand Down Expand Up @@ -265,7 +268,7 @@ func (b *CloudSlack) start(ctx context.Context) error {
state := removeBotNameFromIDs(b.BotName(), callback.BlockActionState)

userName := b.getRealNameWithFallbackToUserID(ctx, callback.User.ID)
msg := socketSlackMessage{
msg := slackMessage{
Text: cmd,
Channel: channelID,
ThreadTimeStamp: threadTs,
Expand All @@ -274,6 +277,7 @@ func (b *CloudSlack) start(ctx context.Context) error {
UserName: userName,
CommandOrigin: cmdOrigin,
State: state,
EventTimeStamp: callback.Message.Timestamp,
ResponseURL: callback.ResponseURL,
BlockID: act.BlockID,
}
Expand All @@ -289,12 +293,13 @@ func (b *CloudSlack) start(ctx context.Context) error {

cmd, cmdOrigin := resolveBlockActionCommand(act)
userName := b.getRealNameWithFallbackToUserID(ctx, callback.User.ID)
msg := socketSlackMessage{
Text: cmd,
Channel: callback.View.PrivateMetadata,
UserID: callback.User.ID,
UserName: userName,
CommandOrigin: cmdOrigin,
msg := slackMessage{
Text: cmd,
Channel: callback.View.PrivateMetadata,
UserID: callback.User.ID,
UserName: userName,
EventTimeStamp: "", // there is no timestamp for interactive callbacks
CommandOrigin: cmdOrigin,
}

if err := b.handleMessage(ctx, msg); err != nil {
Expand All @@ -313,7 +318,7 @@ func (b *CloudSlack) start(ctx context.Context) error {
func (b *CloudSlack) SendMessage(ctx context.Context, msg interactive.CoreMessage, sourceBindings []string) error {
errs := multierror.New()
for _, channelName := range b.getChannelsToNotify(sourceBindings) {
msgMetadata := socketSlackMessage{
msgMetadata := slackMessage{
Channel: channelName,
ThreadTimeStamp: "",
BlockID: uuid.New().String(),
Expand All @@ -332,7 +337,7 @@ func (b *CloudSlack) SendMessageToAll(ctx context.Context, msg interactive.CoreM
errs := multierror.New()
for _, channel := range b.getChannels() {
channelName := channel.Name
msgMetadata := socketSlackMessage{
msgMetadata := slackMessage{
Channel: channelName,
BlockID: uuid.New().String(),
}
Expand Down Expand Up @@ -374,7 +379,7 @@ func (b *CloudSlack) getRealNameWithFallbackToUserID(ctx context.Context, userID
return user.RealName
}

func (b *CloudSlack) handleMessage(ctx context.Context, event socketSlackMessage) error {
func (b *CloudSlack) handleMessage(ctx context.Context, event slackMessage) error {
// Handle message only if starts with mention
request, found := b.findAndTrimBotMention(event.Text)
if !found {
Expand Down Expand Up @@ -418,16 +423,22 @@ func (b *CloudSlack) handleMessage(ctx context.Context, event socketSlackMessage
DisplayName: event.UserName,
},
})

msgRef := b.msgStatusTracker.GetMsgRef(event)
b.msgStatusTracker.MarkAsReceived(msgRef)

response := e.Execute(ctx)
err = b.send(ctx, event, response)
if err != nil {
return fmt.Errorf("while sending message: %w", err)
}

b.msgStatusTracker.MarkAsProcessed(msgRef)

return nil
}

func (b *CloudSlack) send(ctx context.Context, event socketSlackMessage, resp interactive.CoreMessage) error {
func (b *CloudSlack) send(ctx context.Context, event slackMessage, resp interactive.CoreMessage) error {
b.log.Debugf("Sending message to channel %q: %+v", event.Channel, resp)

resp.ReplaceBotNamePlaceholder(b.BotName(), api.BotNameWithClusterName(b.clusterName))
Expand Down Expand Up @@ -509,7 +520,7 @@ func (b *CloudSlack) BotName() string {
return fmt.Sprintf("<@%s>", b.botID)
}

func (b *CloudSlack) getThreadOptionIfNeeded(event socketSlackMessage, file *slack.File) slack.MsgOption {
func (b *CloudSlack) getThreadOptionIfNeeded(event slackMessage, file *slack.File) slack.MsgOption {
//if the message is from thread then add an option to return the response to the thread
if event.ThreadTimeStamp != "" {
return slack.MsgOptionTS(event.ThreadTimeStamp)
Expand Down
File renamed without changes.
16 changes: 8 additions & 8 deletions pkg/bot/slack.go → pkg/bot/slack_legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

// TODO: Refactor this file as a part of https://github.com/kubeshop/botkube/issues/667
// - handle and send methods from `slackMessage` should be defined on Bot level,
// - handle and send methods from `slackLegacyMessage` should be defined on Bot level,
// - split to multiple files in a separate package,
// - review all the methods and see if they can be simplified.

Expand Down Expand Up @@ -50,8 +50,8 @@ type Slack struct {
renderer *SlackRenderer
}

// slackMessage contains message details to execute command and send back the result
type slackMessage struct {
// slackLegacyMessage contains message details to execute command and send back the result
type slackLegacyMessage struct {
Text string
Channel string
ThreadTimeStamp string
Expand Down Expand Up @@ -126,7 +126,7 @@ func (b *Slack) Start(ctx context.Context) error {
if ev.User == b.botID {
continue
}
sm := slackMessage{
sm := slackLegacyMessage{
Text: ev.Text,
Channel: ev.Channel,
ThreadTimeStamp: ev.ThreadTimestamp,
Expand Down Expand Up @@ -202,7 +202,7 @@ func (b *Slack) SetNotificationsEnabled(channelName string, enabled bool) error
return nil
}

func (b *Slack) handleMessage(ctx context.Context, msg slackMessage) error {
func (b *Slack) handleMessage(ctx context.Context, msg slackLegacyMessage) error {
// Handle message only if starts with mention
request, found := b.findAndTrimBotMention(msg.Text)
if !found {
Expand Down Expand Up @@ -253,7 +253,7 @@ func (b *Slack) handleMessage(ctx context.Context, msg slackMessage) error {
return nil
}

func (b *Slack) send(ctx context.Context, msg slackMessage, resp interactive.CoreMessage, onlyVisibleToUser bool) error {
func (b *Slack) send(ctx context.Context, msg slackLegacyMessage, resp interactive.CoreMessage, onlyVisibleToUser bool) error {
b.log.Debugf("Sending message to channel %q: %+v", msg.Channel, msg)

resp.ReplaceBotNamePlaceholder(b.BotName())
Expand Down Expand Up @@ -314,7 +314,7 @@ func (b *Slack) getChannelsToNotify(sourceBindings []string) []string {
func (b *Slack) SendMessage(ctx context.Context, msg interactive.CoreMessage, sourceBindings []string) error {
errs := multierror.New()
for _, channelName := range b.getChannelsToNotify(sourceBindings) {
msgMetadata := slackMessage{
msgMetadata := slackLegacyMessage{
Channel: channelName,
ThreadTimeStamp: "",
}
Expand All @@ -333,7 +333,7 @@ func (b *Slack) SendMessageToAll(ctx context.Context, msg interactive.CoreMessag
errs := multierror.New()
for _, channel := range b.getChannels() {
channelName := channel.Name
msgMetadata := slackMessage{
msgMetadata := slackLegacyMessage{
Channel: channelName,
ThreadTimeStamp: "",
}
Expand Down
File renamed without changes.
80 changes: 80 additions & 0 deletions pkg/bot/slack_msg_tracker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package bot

import (
"fmt"

"github.com/sirupsen/logrus"
"github.com/slack-go/slack"
)

const (
msgReceivedEmoji = "eyes"
msgProcessedEmoji = "white_check_mark"
)

// SlackReactionClient defines the interface for managing reactions on Slack messages.
type SlackReactionClient interface {
AddReaction(name string, item slack.ItemRef) error
RemoveReaction(name string, item slack.ItemRef) error
}

// SlackMessageStatusTracker marks messages with emoji for easy tracking the status of Slack messages.
type SlackMessageStatusTracker struct {
log logrus.FieldLogger
client SlackReactionClient
}

// NewSlackMessageStatusTracker creates a new instance of SlackMessageStatusTracker.
func NewSlackMessageStatusTracker(log logrus.FieldLogger, client SlackReactionClient) *SlackMessageStatusTracker {
return &SlackMessageStatusTracker{log: log, client: client}
}

// GetMsgRef retrieves the Slack item reference for a given event.
// It returns nil if the message doesn't support reactions or lacks necessary information.
func (b *SlackMessageStatusTracker) GetMsgRef(event slackMessage) *slack.ItemRef {
// We may not have it when it is visible only to a user,
// or it was a modal that has only a trigger ID.
if event.EventTimeStamp == "" || event.Channel == "" {
b.log.WithField("commandOrigin", event.CommandOrigin).Debug("Message doesn't support reactions. Skipping...")
return nil
}
ref := slack.NewRefToMessage(event.Channel, event.EventTimeStamp)
return &ref
}

// MarkAsReceived marks a message as received by adding the "eyes" reaction.
// If msgRef is nil, no action is performed.
func (b *SlackMessageStatusTracker) MarkAsReceived(msgRef *slack.ItemRef) {
if msgRef == nil {
return
}
err := b.client.AddReaction(msgReceivedEmoji, *msgRef)
b.handleReactionError(err, "received")
}

// MarkAsProcessed marks a message as processed by removing the "eyes" reaction and adding the "heavy_check_mark" reaction.
// If msgRef is nil, no action is performed.
func (b *SlackMessageStatusTracker) MarkAsProcessed(msgRef *slack.ItemRef) {
if msgRef == nil {
return
}

_ = b.client.RemoveReaction(msgReceivedEmoji, *msgRef) // The reaction may be missing as there was an error earlier.

err := b.client.AddReaction(msgProcessedEmoji, *msgRef)
b.handleReactionError(err, "processed")
}

func (b *SlackMessageStatusTracker) handleReactionError(err error, ctx string) {
logMsg := fmt.Sprintf("Cannot mark message as %s.", ctx)
switch terr := err.(type) {
case nil:
// No error occurred, do nothing.
case slack.SlackErrorResponse:
b.log.WithFields(logrus.Fields{
"messages": terr.ResponseMetadata.Messages,
}).WithError(err).Warn(logMsg)
default:
b.log.WithError(err).Warn(logMsg)
}
}
17 changes: 17 additions & 0 deletions pkg/bot/slack_utils.go → pkg/bot/slack_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"regexp"

"github.com/sirupsen/logrus"
"github.com/slack-go/slack"

"github.com/kubeshop/botkube/pkg/config"
conversationx "github.com/kubeshop/botkube/pkg/conversation"
"github.com/kubeshop/botkube/pkg/execute/command"
)

const slackBotMentionPrefixFmt = "^<@%s>"
Expand Down Expand Up @@ -39,3 +41,18 @@ func slackBotMentionRegex(botID string) (*regexp.Regexp, error) {

return botMentionRegex, nil
}

// slackMessage contains message details to execute command and send back the result
type slackMessage struct {
Text string
Channel string
ThreadTimeStamp string
UserID string
UserName string
TriggerID string
CommandOrigin command.Origin
State *slack.BlockActionStates
ResponseURL string
BlockID string
EventTimeStamp string
}
Loading

0 comments on commit 2242de4

Please sign in to comment.