Skip to content

Commit

Permalink
Add ability to respond to a message reaction being added or remove (#520
Browse files Browse the repository at this point in the history
)
  • Loading branch information
ArcticSnowman authored Oct 28, 2024
1 parent dc4cfeb commit 3bd3031
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 19 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test-coverage:
clean: validate
@echo "Running $@ tasks"
-rm -v ./flottbot*
-rm -v ./debug
-rm -v ./debug #Not created

# ┌┐ ┬ ┬┬┬ ┌┬┐
# ├┴┐│ │││ ││
Expand Down
11 changes: 11 additions & 0 deletions config-example/rules/reaction.yaml
Original file line number Diff line number Diff line change
@@ -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
53 changes: 45 additions & 8 deletions core/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)

Expand Down
41 changes: 33 additions & 8 deletions core/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -625,26 +645,30 @@ 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)
failVars["_user.name"] = "fooUser"
testMessageFail.Vars = failVars

testRuleUserAllowed := models.Rule{}
testRuleUserAllowed.Hear = "stuff"
testRuleUserAllowed.AllowUsers = []string{"fooUser"}
testMessageUserAllowed := new(models.Message)
userAllowedVars := make(map[string]string)
userAllowedVars["_user.name"] = "fooUser"
testMessageUserAllowed.Vars = userAllowedVars

testRuleNeedArg := models.Rule{}
testRuleNeedArg.Respond = "stuff"
testRuleNeedArg.AllowUsers = []string{"fooUser"}
testRuleNeedArg.Args = []string{"arg1", "arg2"}
testMessageNeedArg := new(models.Message)
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions models/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Message struct {
ChannelName string
Input string
Output string
ReactionAdded string
ReactionRemoved string
Error string
Timestamp string
ThreadID string
Expand Down
2 changes: 2 additions & 0 deletions models/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
86 changes: 84 additions & 2 deletions remote/slack/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 3bd3031

Please sign in to comment.