diff --git a/Makefile b/Makefile index 6687d3dd..9ac5ec28 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ test-coverage: clean: validate @echo "Running $@ tasks" -rm -v ./flottbot* - -rm -v ./debug + -rm -v ./debug #Not created # ┌┐ ┬ ┬┬┬ ┌┬┐ # ├┴┐│ │││ ││ diff --git a/config-example/rules/reaction.yaml b/config-example/rules/reaction.yaml new file mode 100644 index 00000000..76f910fe --- /dev/null +++ b/config-example/rules/reaction.yaml @@ -0,0 +1,11 @@ +name: question-reaction +active: true +reactions_added: "question" +args: +# actions +actions: # no actions +# response +format_output: ${_user.firstname} could you elaborate more on your request? + +start_message_thread: true # start a thread with the response +direct_message_only: false # allow messaging inside channels diff --git a/core/matcher.go b/core/matcher.go index 237ab7cf..f94ebcde 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, rule) // Determine what service we are processing the rule for switch message.Service { case models.MsgServiceChat, models.MsgServiceCLI: @@ -63,25 +63,54 @@ 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(message models.Message, rule models.Rule) (string, bool) { processedInput, hit := "", false - if ruleRespondValue != "" { + + ruleRespondValue := rule.Respond + ruleHearValue := rule.Hear + + if rule.Respond != "" { + messageInput := message.Input processedInput, hit = utils.Match(ruleRespondValue, messageInput, true) - } else if ruleHearValue != "" { // Are we listening to everything? + } else if rule.Hear != "" { // Are we listening to everything? + messageInput := message.Input _, hit = utils.Match(ruleHearValue, messageInput, false) + } else if rule.ReactionsAdded != "" { + messageReaction := message.ReactionAdded + processedInput, hit = utils.Match(rule.ReactionsAdded, messageReaction, false) + } else if rule.ReactionsRemoved != "" { + messageReaction := message.ReactionRemoved + processedInput, hit = utils.Match(rule.ReactionsRemoved, messageReaction, false) } return processedInput, hit } +// isChatRule checks that the rule applies to chat. +func isValidChatRule(rule models.Rule) bool { + if rule.Respond != "" { + return true + } + + if rule.Hear != "" { + return true + } + + if rule.ReactionsAdded != "" || rule.ReactionsRemoved != "" { + return true + } + + return false +} + // handleChatServiceRule handles the processing logic for a rule that came from either the chat application or CLI remote. // //nolint:gocyclo // refactor candidate 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 isValidChatRule(rule) { + // You can only use 'respond', 'hear', or 'reactions' 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) } @@ -209,7 +238,15 @@ func handleNoMatch(outputMsgs chan<- models.Message, message models.Message, hit } // isValidHitChatRule does additional checks on a successfully hit rule that came from the chat or CLI service. +// +//nolint:gocyclo // refactor func isValidHitChatRule(message *models.Message, rule models.Rule, processedInput string, bot *models.Bot) bool { + // Check rule has one of Hear, Respond, ReactionsAdded or ReactionsRemoved + if !isValidChatRule(rule) { + message.Output = "Rule does not have one of Hear, Respond, ReactionsAdded or ReactionsRemoved defined " + return false + } + // Check to honor allow_users or allow_usergroups canRunRule := utils.CanTrigger(message.Vars["_user.name"], message.Vars["_user.id"], rule, bot) if !canRunRule { @@ -230,8 +267,8 @@ func isValidHitChatRule(message *models.Message, rule models.Rule, processedInpu message.Vars[name] = value } - // If this wasn't a 'hear' rule, handle the args - if rule.Hear == "" { + // If this is a "respond" type, handle args + if rule.Respond != "" { // 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..a35769e6 100644 --- a/core/matcher_test.go +++ b/core/matcher_test.go @@ -584,9 +584,13 @@ 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 + messageReactionAdded string + messageReactionRemoved string + ruleReactionAdded string + ruleReactionRemoved string } tests := []struct { @@ -595,15 +599,31 @@ 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 added", args{"", "", "", "hello", "", "hello", ""}, "hello", true}, + {"hit reaction removed", 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) + rule := models.Rule{ + Hear: tt.args.ruleHearValue, + Respond: tt.args.ruleRespondValue, + ReactionsAdded: tt.args.ruleReactionAdded, + ReactionsRemoved: tt.args.ruleReactionRemoved, + } + + message := models.Message{ + Input: tt.args.messageInput, + ReactionAdded: tt.args.messageReactionAdded, + ReactionRemoved: tt.args.messageReactionRemoved, + } + + got, got1 := getProccessedInputAndHitValue(message, rule) + if got != tt.want { t.Errorf("getProccessedInputAndHitValue() got = %v, want %v", got, tt.want) } @@ -625,12 +645,14 @@ func Test_isValidHitChatRule(t *testing.T) { testBot := new(models.Bot) testRule := models.Rule{} + testRule.Hear = "stuff" testMessage := new(models.Message) happyVars := make(map[string]string) happyVars["_user.name"] = "fooUser" testMessage.Vars = happyVars testRuleFail := models.Rule{} + testRuleFail.Hear = "stuff" testRuleFail.AllowUsers = []string{"barUser"} testMessageFail := new(models.Message) failVars := make(map[string]string) @@ -638,6 +660,7 @@ func Test_isValidHitChatRule(t *testing.T) { testMessageFail.Vars = failVars testRuleUserAllowed := models.Rule{} + testRuleUserAllowed.Hear = "stuff" testRuleUserAllowed.AllowUsers = []string{"fooUser"} testMessageUserAllowed := new(models.Message) userAllowedVars := make(map[string]string) @@ -645,6 +668,7 @@ func Test_isValidHitChatRule(t *testing.T) { testMessageUserAllowed.Vars = userAllowedVars testRuleNeedArg := models.Rule{} + testRuleNeedArg.Respond = "stuff" testRuleNeedArg.AllowUsers = []string{"fooUser"} testRuleNeedArg.Args = []string{"arg1", "arg2"} testMessageNeedArg := new(models.Message) @@ -653,6 +677,7 @@ func Test_isValidHitChatRule(t *testing.T) { testMessageNeedArg.Vars = needArgVars testRuleArgs := models.Rule{} + testRuleArgs.Respond = "stuff" testRuleArgs.AllowUsers = []string{"fooUser"} testRuleArgs.Args = []string{"arg1", "arg2"} testMessageArgs := new(models.Message) diff --git a/models/message.go b/models/message.go index b75f1e05..254df4d0 100644 --- a/models/message.go +++ b/models/message.go @@ -17,6 +17,8 @@ type Message struct { ChannelName string Input string Output string + ReactionAdded string + ReactionRemoved string Error string Timestamp string ThreadID string diff --git a/models/rule.go b/models/rule.go index 5aadbef7..2dfbcc25 100644 --- a/models/rule.go +++ b/models/rule.go @@ -7,6 +7,8 @@ type Rule struct { Name string `mapstructure:"name" binding:"required"` Respond string `mapstructure:"respond" binding:"omitempty"` Hear string `mapstructure:"hear" binding:"omitempty"` + ReactionsAdded string `mapstructure:"reactions_added" binding:"omitempty"` + ReactionsRemoved string `mapstructure:"reactions_removed" 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..36198e6a 100644 --- a/remote/slack/helper.go +++ b/remote/slack/helper.go @@ -445,6 +445,28 @@ 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: + switch action { + case "added": + message.ReactionAdded = reaction + case "removed": + message.ReactionRemoved = reaction + } + + message.Vars["_reaction.added"] = message.ReactionAdded + message.Vars["_reaction.removed"] = message.ReactionRemoved + + message = populateMessage(message, msgType, channel, "", timeStamp, "", link, false, user, bot) + 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) { @@ -479,7 +501,7 @@ func readFromEventsAPI(api *slack.Client, vToken string, inputMsgs chan<- models // // https://api.slack.com/apis/connections/socket // -//nolint:gocyclo // needs refactor +//nolint:gocyclo,funlen // needs refactor func readFromSocketMode(sm *slack.Client, inputMsgs chan<- models.Message, bot *models.Bot) { // setup the client client := socketmode.New(sm) @@ -509,7 +531,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 +586,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 {