From 6ed7ff330eab09d0e9b390966d347662795f3bfd Mon Sep 17 00:00:00 2001 From: Steven Arnott Date: Wed, 23 Oct 2024 13:51:48 -0400 Subject: [PATCH] Add reaction handling --- core/matcher.go | 12 ++-- core/matcher_test.go | 19 +++--- models/message.go | 2 + models/rule.go | 1 + remote/slack/helper.go | 131 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/core/matcher.go b/core/matcher.go index 237ab7cf..0fcaee8d 100644 --- a/core/matcher.go +++ b/core/matcher.go @@ -38,7 +38,7 @@ RuleSearch: // Only check active rules. if rule.Active { // Init some variables for use below - processedInput, hit := getProccessedInputAndHitValue(message.Input, rule.Respond, rule.Hear) + processedInput, hit := getProccessedInputAndHitValue(message.Input, rule.Respond, rule.Hear, message.Reaction, rule.ReactionMatch) // Determine what service we are processing the rule for switch message.Service { case models.MsgServiceChat, models.MsgServiceCLI: @@ -63,12 +63,14 @@ RuleSearch: } // getProccessedInputAndHitValue gets the processed input from the message input and the true/false if it was a successfully hit rule. -func getProccessedInputAndHitValue(messageInput, ruleRespondValue, ruleHearValue string) (string, bool) { +func getProccessedInputAndHitValue(messageInput, ruleRespondValue, ruleHearValue, messageReaction, ruleReactionMatch string) (string, bool) { processedInput, hit := "", false if ruleRespondValue != "" { processedInput, hit = utils.Match(ruleRespondValue, messageInput, true) } else if ruleHearValue != "" { // Are we listening to everything? _, hit = utils.Match(ruleHearValue, messageInput, false) + } else if ruleReactionMatch != "" { + processedInput, hit = utils.Match(ruleReactionMatch, messageReaction, false) } return processedInput, hit @@ -80,8 +82,8 @@ func getProccessedInputAndHitValue(messageInput, ruleRespondValue, ruleHearValue func handleChatServiceRule(outputMsgs chan<- models.Message, message models.Message, hitRule chan<- models.Rule, rule models.Rule, processedInput string, hit bool, bot *models.Bot) (bool, bool) { match, stopSearch := false, false - if rule.Respond != "" || rule.Hear != "" { - // You can only use 'respond' OR 'hear' + if rule.Respond != "" || rule.Hear != "" || rule.ReactionMatch != "" { + // You can only use 'respond', 'hear', or 'reaction match' if rule.Respond != "" && rule.Hear != "" { log.Debug().Msgf("rule %#q has both 'hear' and 'match' or 'respond' defined. please choose one or the other", rule.Name) } @@ -231,7 +233,7 @@ func isValidHitChatRule(message *models.Message, rule models.Rule, processedInpu } // If this wasn't a 'hear' rule, handle the args - if rule.Hear == "" { + if rule.Hear == "" && rule.ReactionMatch == "" { // Get all the args that the message sender supplied args := utils.RuleArgTokenizer(processedInput) diff --git a/core/matcher_test.go b/core/matcher_test.go index e7c1d71d..40649811 100644 --- a/core/matcher_test.go +++ b/core/matcher_test.go @@ -584,9 +584,11 @@ func TestUpdateReaction(t *testing.T) { func Test_getProccessedInputAndHitValue(t *testing.T) { type args struct { - messageInput string - ruleRespondValue string - ruleHearValue string + messageInput string + ruleRespondValue string + ruleHearValue string + messageReaction string + ruleReactionMatch string } tests := []struct { @@ -595,15 +597,16 @@ func Test_getProccessedInputAndHitValue(t *testing.T) { want string want1 bool }{ - {"hit", args{"hello foo", "hello", "hello"}, "foo", true}, - {"hit no hear value", args{"hello foo", "hello", ""}, "foo", true}, - {"hit no respond value - drops args", args{"hello foo", "", "hello"}, "", true}, - {"no match", args{"hello foo", "", ""}, "", false}, + {"hit", args{"hello foo", "hello", "hello", "", ""}, "foo", true}, + {"hit no hear value", args{"hello foo", "hello", "", "", ""}, "foo", true}, + {"hit no respond value - drops args", args{"hello foo", "", "hello", "", ""}, "", true}, + {"no match", args{"hello foo", "", "", "", ""}, "", false}, + {"hit reaction", args{"", "", "", ":hello:", ":hello:"}, ":hello:", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, got1 := getProccessedInputAndHitValue(tt.args.messageInput, tt.args.ruleRespondValue, tt.args.ruleHearValue) + got, got1 := getProccessedInputAndHitValue(tt.args.messageInput, tt.args.ruleRespondValue, tt.args.ruleHearValue, tt.args.messageReaction, tt.args.ruleReactionMatch) if got != tt.want { t.Errorf("getProccessedInputAndHitValue() got = %v, want %v", got, tt.want) } diff --git a/models/message.go b/models/message.go index b75f1e05..3065e0ee 100644 --- a/models/message.go +++ b/models/message.go @@ -17,6 +17,8 @@ type Message struct { ChannelName string Input string Output string + ReactionAction string + Reaction string Error string Timestamp string ThreadID string diff --git a/models/rule.go b/models/rule.go index 5aadbef7..45f39e61 100644 --- a/models/rule.go +++ b/models/rule.go @@ -7,6 +7,7 @@ type Rule struct { Name string `mapstructure:"name" binding:"required"` Respond string `mapstructure:"respond" binding:"omitempty"` Hear string `mapstructure:"hear" binding:"omitempty"` + ReactionMatch string `mapstructure:"reaction_match" binding:"omitempty"` Schedule string `mapstructure:"schedule"` Args []string `mapstructure:"args" binding:"required"` DirectMessageOnly bool `mapstructure:"direct_message_only" binding:"required"` diff --git a/remote/slack/helper.go b/remote/slack/helper.go index 5c6efd22..a95626fa 100644 --- a/remote/slack/helper.go +++ b/remote/slack/helper.go @@ -445,6 +445,75 @@ func populateMessage(message models.Message, msgType models.MessageType, channel } } +// populateMessage - populates the 'Message' object to be passed on for processing/sending. +func populateReaction(message models.Message, msgType models.MessageType, channel, action, reaction, timeStamp, link string, user *slack.User, bot *models.Bot) models.Message { + switch msgType { + case models.MsgTypeDirect, models.MsgTypeChannel, models.MsgTypePrivateChannel: + // Populate message attributes + message.Type = msgType + message.Service = models.MsgServiceChat + message.ChannelID = channel + message.ReactionAction = action + message.Reaction = reaction + message.Input = "" + message.Output = "" + message.Timestamp = timeStamp + message.SourceLink = link + + // If the message read was not a dm, get the name of the channel it came from + if msgType != models.MsgTypeDirect { + name, ok := findKey(bot.Rooms, channel) + if !ok { + log.Error().Msgf("could not find name of channel %#q", channel) + } + + message.ChannelName = name + } + + message.Vars["_reaction.action"] = action + message.Vars["_reaction"] = reaction + // make channel variables available + message.Vars["_channel.id"] = message.ChannelID + message.Vars["_channel.name"] = message.ChannelName // will be empty if it came via DM + + // make link to trigger message available + message.Vars["_source.link"] = message.SourceLink + + // make timestamp information available + message.Vars["_source.timestamp"] = timeStamp + + // Populate message with user information (i.e. who sent the message) + // These will be accessible on rules via ${_user.email}, ${_user.id}, etc. + if user != nil { // nil user implies a message from an api/bot (i.e. not an actual user) + message.Vars["_user.id"] = user.ID + message.Vars["_user.teamid"] = user.TeamID + message.Vars["_user.name"] = user.Name + message.Vars["_user.color"] = user.Color + message.Vars["_user.realname"] = user.RealName + message.Vars["_user.tz"] = user.TZ + message.Vars["_user.tzlabel"] = user.TZLabel + message.Vars["_user.tzoffset"] = strconv.Itoa(user.TZOffset) + message.Vars["_user.firstname"] = user.Profile.FirstName + message.Vars["_user.lastname"] = user.Profile.LastName + message.Vars["_user.realnamenormalized"] = user.Profile.RealNameNormalized + message.Vars["_user.displayname"] = user.Profile.DisplayName + message.Vars["_user.displaynamenormalized"] = user.Profile.DisplayNameNormalized + message.Vars["_user.email"] = user.Profile.Email + message.Vars["_user.skype"] = user.Profile.Skype + message.Vars["_user.phone"] = user.Profile.Phone + message.Vars["_user.title"] = user.Profile.Title + message.Vars["_user.statustext"] = user.Profile.StatusText + message.Vars["_user.statusemoji"] = user.Profile.StatusEmoji + message.Vars["_user.team"] = user.Profile.Team + } + + return message + default: + log.Debug().Msgf("read message of unsupported type '%T' - unable to populate message attributes", msgType) + return message + } +} + // readFromEventsAPI utilizes the Slack API client to read event-based messages. // This method of reading is preferred over the RTM method. func readFromEventsAPI(api *slack.Client, vToken string, inputMsgs chan<- models.Message, bot *models.Bot) { @@ -509,7 +578,7 @@ func readFromSocketMode(sm *slack.Client, inputMsgs chan<- models.Message, bot * innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { - case *slackevents.AppMentionEvent, *slackevents.ReactionAddedEvent: + case *slackevents.AppMentionEvent: continue case *slackevents.MessageEvent: senderID := ev.User @@ -564,6 +633,66 @@ func readFromSocketMode(sm *slack.Client, inputMsgs chan<- models.Message, bot * inputMsgs <- populateMessage(models.NewMessage(), msgType, channel, text, timestamp, threadTimestamp, link, mentioned, user, bot) } + case *slackevents.ReactionAddedEvent: + senderID := ev.User + + if senderID != "" && bot.ID != senderID { + channel := ev.Item.Channel + + // determine the message type + msgType, err := getMessageType(channel) + if err != nil { + log.Error().Msg(err.Error()) + } + + // get information on the user + user, err := sm.GetUserInfo(senderID) + if err != nil { + log.Error().Msgf("did not get slack user info: %s", err.Error()) + } + + timestamp := ev.Item.Timestamp + + reaction := ev.Reaction + + // get the link to the message, will be empty string if there's an error + link, err := sm.GetPermalink(&slack.PermalinkParameters{Channel: channel, Ts: timestamp}) + if err != nil { + log.Error().Msgf("unable to retrieve link to message: %s", err.Error()) + } + + inputMsgs <- populateReaction(models.NewMessage(), msgType, channel, "added", reaction, timestamp, link, user, bot) + } + case *slackevents.ReactionRemovedEvent: + senderID := ev.User + + if senderID != "" && bot.ID != senderID { + channel := ev.Item.Channel + + // determine the message type + msgType, err := getMessageType(channel) + if err != nil { + log.Error().Msg(err.Error()) + } + + // get information on the user + user, err := sm.GetUserInfo(senderID) + if err != nil { + log.Error().Msgf("did not get slack user info: %s", err.Error()) + } + + timestamp := ev.Item.Timestamp + + reaction := ev.Reaction + + // get the link to the message, will be empty string if there's an error + link, err := sm.GetPermalink(&slack.PermalinkParameters{Channel: channel, Ts: timestamp}) + if err != nil { + log.Error().Msgf("unable to retrieve link to message: %s", err.Error()) + } + + inputMsgs <- populateReaction(models.NewMessage(), msgType, channel, "removed", reaction, timestamp, link, user, bot) + } case *slackevents.MemberJoinedChannelEvent: // limit to our bot if ev.User == bot.ID {