diff --git a/examples/reaction-bot.go b/examples/reaction-bot.go index eb378d3..7218502 100644 --- a/examples/reaction-bot.go +++ b/examples/reaction-bot.go @@ -5,9 +5,9 @@ import ( ) var registeredReactions = reactionbot.RegisteredReactions{ - "white_check_mark": { + "raised_hands": { Name: "Reaction Bot Testing", - Channel: "reaction-bot-testing", + Channel: "til", }, } diff --git a/go.mod b/go.mod index dd86ff8..6c263bd 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/jordanleven/reaction-bot go 1.16 require ( - github.com/davecgh/go-spew v1.1.1 github.com/fatih/color v1.10.0 github.com/joho/godotenv v1.3.0 github.com/pkg/errors v0.9.1 // indirect diff --git a/reactionbot/client.go b/reactionbot/client.go new file mode 100644 index 0000000..8dfab1f --- /dev/null +++ b/reactionbot/client.go @@ -0,0 +1,180 @@ +package reactionbot + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +type SlackClient = slack.Client +type SlackSocketClient = socketmode.Client +type SlackUser = slack.User +type SlackReactionAddedEvent = slackevents.ReactionAddedEvent +type SlackPostMessageOptions struct { + ReactedByUser User + Attachment *ReactionAttachment + Message string +} + +func newSlackClient(options RegistrationOptions) *SlackClient { + return slack.New( + options.SlackTokenBot, + slack.OptionDebug(false), + slack.OptionAppLevelToken(options.SlackTokenApp), + ) +} + +func (r reactionBot) getSlackUsers() ([]SlackUser, error) { + return r.SlackClient.GetUsers() +} + +func (r reactionBot) newSlackSocketClient() *SlackSocketClient { + return socketmode.New(r.SlackClient) +} + +func getNumberOfMessageReactions(message slack.Message, reactionEmoji string) int { + reactions := message.Reactions + reactionCount := 0 + for _, reaction := range reactions { + messageEmoji := reaction.Name + if messageEmoji == reactionEmoji { + reactionCount = reaction.Count + } + } + return reactionCount +} + +func messageIsReactedMessage(emoji string, timestamp string, message slack.Message) bool { + messageHasCorrectReaction := false + messageTimestamp := message.Timestamp + messageReactions := message.Reactions + for _, reaction := range messageReactions { + messageReactionEmoji := reaction.Name + if messageReactionEmoji == emoji { + messageHasCorrectReaction = true + } + } + + return messageTimestamp == timestamp && messageHasCorrectReaction +} + +func (r reactionBot) getReactedMessage(reactionEmoji string, reactionItem slackevents.Item) slack.Message { + var reactedMessage slack.Message + timestamp := reactionItem.Timestamp + channelID := reactionItem.Channel + payload := slack.GetConversationRepliesParameters{ + ChannelID: channelID, + Timestamp: timestamp, + // Required to show messages that are at the limit of the timestamp + Inclusive: true, + } + conversationHistory, _, _, _ := r.Slack.GetConversationReplies(&payload) + + messageTextFound := false + conversationHistoryLength := len(conversationHistory) + index := 0 + for !messageTextFound && index < conversationHistoryLength { + for messageIndex, message := range conversationHistory { + if messageIsReactedMessage(reactionEmoji, timestamp, message) { + messageTextFound = true + reactedMessage = message + } + index = messageIndex + } + } + return reactedMessage +} + +func (r reactionBot) getFormattedEvent(innerEvent slackevents.EventsAPIInnerEvent) ReactionEvent { + evt := innerEvent.Data.(*SlackReactionAddedEvent) + evtReactionEmoji := evt.Reaction + evtItem := evt.Item + reactedMessage := r.getReactedMessage(evtReactionEmoji, evtItem) + reactionCount := getNumberOfMessageReactions(reactedMessage, evtReactionEmoji) + formattedEvent := ReactionEvent{ + ReactionEmoji: evtReactionEmoji, + ReactionTimestamp: evt.Item.Timestamp, + ReactionCount: reactionCount, + UserIDReactedBy: evt.User, + UserIDReactedTo: evt.ItemUser, + Message: reactedMessage.Text, + } + + if len(reactedMessage.Files) > 0 { + f := reactedMessage.Files[0] + formattedEvent.MessageAttachment = &ReactionAttachment{ + Name: f.Name, + Permalink: f.Permalink, + } + } + + return formattedEvent +} + +func getInnerEvent(event socketmode.Event) slackevents.EventsAPIInnerEvent { + evt, _ := event.Data.(slackevents.EventsAPIEvent) + return evt.InnerEvent +} + +func (r reactionBot) handleSlackEvents(callback func(ReactionEvent)) { + client := r.newSlackSocketClient() + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeConnecting: + color.White("Connecting to Slack with Socket Mode...") + case socketmode.EventTypeConnectionError: + color.Red("Connection failed. Retrying later...") + case socketmode.EventTypeDisconnect: + color.Red("Disconnecting!") + case socketmode.EventTypeConnected: + color.Green("Connected to Slack with Socket Mode.") + case socketmode.EventTypeHello: + color.Green("Well hello there! Reaction Bot has finish starting up.") + case socketmode.EventTypeEventsAPI: + client.Ack(*evt.Request) + innerEvent := getInnerEvent(evt) + if innerEvent.Type == slackevents.ReactionAdded { + event := r.getFormattedEvent(innerEvent) + callback(event) + } + default: + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + } + } + }() + + client.Run() +} + +func (r reactionBot) postSlackMessage(channel string, opts SlackPostMessageOptions) (timestamp string, error error) { + reactedMessageBlock := slack.NewTextBlockObject(slack.MarkdownType, opts.Message, false, false) + blocks := []slack.Block{ + slack.NewSectionBlock(reactedMessageBlock, nil, nil), + } + + reactionAttachments := slack.Attachment{} + + if opts.Attachment != nil { + reactionAttachments.ImageURL = opts.Attachment.Permalink + reactionAttachments.Text = opts.Attachment.Name + } + + _, ts, error := r.Slack.PostMessage( + channel, + slack.MsgOptionBlocks(blocks...), + // Fallback text + slack.MsgOptionText(opts.Message, true), + slack.MsgOptionAttachments(reactionAttachments), + slack.MsgOptionAsUser(false), + slack.MsgOptionIconURL(opts.ReactedByUser.ProfileImage), + slack.MsgOptionParse(true), + slack.MsgOptionUsername(opts.ReactedByUser.DisplayName), + ) + + return ts, error +} diff --git a/reactionbot/events.go b/reactionbot/events.go new file mode 100644 index 0000000..fdfb278 --- /dev/null +++ b/reactionbot/events.go @@ -0,0 +1,30 @@ +package reactionbot + +type ReactionAttachment struct { + Permalink string + Name string +} + +type ReactionEvent struct { + ReactionEmoji string + ReactionTimestamp string + ReactionCount int + Message string + MessageAttachment *ReactionAttachment + UserIDReactedTo string + UserIDReactedBy string +} + +func (r reactionBot) messageShouldBePosted(event ReactionEvent) bool { + return r.reactionIsRegistered(event.ReactionEmoji) && event.ReactionCount == 1 +} + +func (r reactionBot) handleReaction(event ReactionEvent) { + if r.messageShouldBePosted(event) { + r.maybePostReactedMessageToChannel(event) + } +} + +func (r reactionBot) handleEvents() { + r.handleSlackEvents(r.handleReaction) +} diff --git a/reactionbot/messages.go b/reactionbot/messages.go index e56c98b..75a923a 100644 --- a/reactionbot/messages.go +++ b/reactionbot/messages.go @@ -3,173 +3,64 @@ package reactionbot import ( "fmt" - "github.com/davecgh/go-spew/spew" "github.com/fatih/color" - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" ) -const ( - //SlackErrorMessageContent the error message from slack when the content is unavailable - SlackErrorMessageContent string = "This content can't be displayed." -) - -func getFormattedMessage(reactedTo string, reactedMessage string) string { - return fmt.Sprintf("\"%s\" \n- @%s", reactedMessage, reactedTo) -} - -func getNumberOfMessageReactions(message slack.Message, reactionEmoji string) int { - reactions := message.Reactions - reactionCount := 0 - for _, reaction := range reactions { - messageEmoji := reaction.Name - if messageEmoji == reactionEmoji { - reactionCount = reaction.Count - } - } - return reactionCount -} - -func messageIsReactedMessage(emoji string, timestamp string, message slack.Message) bool { - messageHasCorrectReaction := false - messageTimestamp := message.Timestamp - messageReactions := message.Reactions - for _, reaction := range messageReactions { - messageReactionEmoji := reaction.Name - if messageReactionEmoji == emoji { - messageHasCorrectReaction = true - } - } - - return messageTimestamp == timestamp && messageHasCorrectReaction -} - -func (bot ReactionBot) getReactedMessage(reactionEmoji string, reactionItem slackevents.Item) slack.Message { - var reactedMessage slack.Message - timestamp := reactionItem.Timestamp - channelID := reactionItem.Channel - payload := slack.GetConversationRepliesParameters{ - ChannelID: channelID, - Timestamp: timestamp, - // Required to show messages that are at the limit of the timestamp - Inclusive: true, - } - conversationHistory, _, _, _ := bot.Slack.GetConversationReplies(&payload) - - messageTextFound := false - conversationHistoryLength := len(conversationHistory) - index := 0 - for !messageTextFound && index < conversationHistoryLength { - for messageIndex, message := range conversationHistory { - if messageIsReactedMessage(reactionEmoji, timestamp, message) { - messageTextFound = true - reactedMessage = message - } - index = messageIndex - } - } - return reactedMessage -} +//SlackErrorMessageContent the error message from slack when the content is unavailable +const SlackErrorMessageContent string = "This content can't be displayed." -func getReactionEmoji(reactionEvent *slackevents.ReactionAddedEvent) string { - return reactionEvent.Reaction +func getAuthorAttribution(reactedTo string) string { + return fmt.Sprintf("\n- @%s", reactedTo) } -func getReactionType(registeredReaction Reaction) string { - return registeredReaction.Name -} - -func getReactionItem(reactionEvent *slackevents.ReactionAddedEvent) slackevents.Item { - return reactionEvent.Item +func getFormattedMessage(reactedTo string, reactedMessage string) string { + attribution := getAuthorAttribution(reactedTo) + return fmt.Sprintf("\"%s\" %s", reactedMessage, attribution) } -// PostReactedMessageToChannel is the function used to post a reaction -func (bot ReactionBot) PostReactedMessageToChannel(reactionEvent *slackevents.ReactionAddedEvent) (channelID string, timetamp string, error error) { - allUsers := bot.Users - reactedByUser := GetUserByUserID(*allUsers, reactionEvent.User) - reactedByName := reactedByUser.DisplayName - reactedToUser := GetUserByUserID(*allUsers, reactionEvent.ItemUser) +func (r reactionBot) postReactedMessageToChannel(channel string, event ReactionEvent) (timetamp string, error error) { + allUsers := r.Users + reactedByUser := getUserByUserID(*allUsers, event.UserIDReactedBy) + reactedToUser := getUserByUserID(*allUsers, event.UserIDReactedTo) reactedToName := reactedToUser.Username + reactedMessage := event.Message + reactedMessageFormatted := getFormattedMessage(reactedToName, reactedMessage) - reactionEmoji := getReactionEmoji(reactionEvent) - registeredReaction := bot.GetRegisteredReaction(reactionEmoji) - reactionType := getReactionType(registeredReaction) - channelToPostReaction := registeredReaction.Channel - reactionItem := getReactionItem(reactionEvent) - reactedMessage := bot.getReactedMessage(reactionEmoji, reactionItem) - reactedMessageText := reactedMessage.Text - reactedMessageFiles := reactedMessage.Files - reactionAttachments := slack.Attachment{} - - if reactedMessageText == SlackErrorMessageContent { - color.Red("Unable to retrieve message for %s reaction to message (dated %s).\n", reactionType, reactedMessage.Timestamp) - color.Red("Message data is posted below\n") - spew.Dump(reactedMessage) - return - } - - if len(reactedMessageFiles) > 0 { - // In case someone decides to add a bunch of photos, we're going to limit them to one - firstReactedFile := reactedMessage.Files[0] - reactionAttachments.ImageURL = firstReactedFile.Permalink - reactionAttachments.Text = " " - + if event.MessageAttachment != nil { // If a user just posted an image, update the message text to be an empty string (the API // requires us to post a non-null string) - if reactedMessageText == "" { - reactedMessageText = ":camera:" + if reactedMessage == "" { + authorAttribution := getAuthorAttribution(reactedToName) + reactedMessageFormatted = fmt.Sprintf(":camera: %s", authorAttribution) } } - reactedMessageTextFormatted := getFormattedMessage(reactedToName, reactedMessageText) - reactedMessageBlock := slack.NewTextBlockObject(slack.MarkdownType, reactedMessageTextFormatted, false, false) - - blocks := []slack.Block{ - slack.NewSectionBlock(reactedMessageBlock, nil, nil), + opts := SlackPostMessageOptions{ + ReactedByUser: reactedByUser, + Attachment: event.MessageAttachment, + Message: reactedMessageFormatted, } - - return bot.Slack.PostMessage( - channelToPostReaction, - slack.MsgOptionBlocks(blocks...), - // Fallback text - slack.MsgOptionText(reactedMessageTextFormatted, true), - slack.MsgOptionAttachments(reactionAttachments), - slack.MsgOptionAsUser(false), - slack.MsgOptionIconURL(reactedByUser.ProfileImage), - slack.MsgOptionParse(true), - slack.MsgOptionUsername(reactedByName), - ) + return r.postSlackMessage(channel, opts) } -// HandleMessageReaction is the function used to handle reactions to a message -func (bot ReactionBot) HandleMessageReaction(reactionEvent *slackevents.ReactionAddedEvent) { - reactionEmoji := getReactionEmoji(reactionEvent) - registeredReaction := bot.GetRegisteredReaction(reactionEmoji) - reactionType := getReactionType(registeredReaction) - reactionItem := getReactionItem(reactionEvent) - reactedMessage := bot.getReactedMessage(reactionEmoji, reactionItem) - reactedMessageText := reactedMessage.Text - count := getNumberOfMessageReactions(reactedMessage, reactionEmoji) - - // If the count is larger than one, then reaction to this specific emoji has already happened and - // we should avoid re-posting - if count > 1 { - return - } +func (r reactionBot) maybePostReactedMessageToChannel(event ReactionEvent) { + registeredReaction := r.getRegisteredReaction(event.ReactionEmoji) + reactionName := registeredReaction.Name + reactionChannel := registeredReaction.Channel + reactedMessageText := event.Message if reactedMessageText == SlackErrorMessageContent { - color.Red("Unable to retrieve message for %s reaction to message (dated %s).\n", reactionType, reactedMessage.Timestamp) + color.Red("Unable to retrieve message for %s reaction to message (dated %s).\n", reactionName, event.ReactionTimestamp) color.Red("Message data is posted below\n") - spew.Dump(reactedMessage) return } - channel, _, err := bot.PostReactedMessageToChannel(reactionEvent) + _, err := r.postReactedMessageToChannel(reactionChannel, event) if err != nil { color.Red("Error posting message to Slack: %s\n", err) return } - color.Green("Successfully sent a \"%s\" reaction to the %s channel.\n", reactionType, channel) + color.Green("Successfully sent a \"%s\" reaction to the %s channel.\n", reactionName, reactionChannel) } diff --git a/reactionbot/reactionbot.go b/reactionbot/reactionbot.go index ce6aaf9..00b5eaf 100644 --- a/reactionbot/reactionbot.go +++ b/reactionbot/reactionbot.go @@ -11,12 +11,12 @@ import ( const refreshIntervalInHours = 4 -// ReactionBot is our main structure -type ReactionBot struct { +type reactionBot struct { Slack *slack.Client + SlackClient *SlackClient IsDevelopment bool RegisteredEmoji RegisteredReactions - Users *SlackUsers + Users *Users } // RegistrationOptions the list of options to init the package @@ -35,33 +35,35 @@ func getSlackInstance(options RegistrationOptions) *slack.Client { ) } -func (bot *ReactionBot) setUpdateTicker() { +func (b *reactionBot) handleUpdateUsers() { ticker := time.NewTicker(time.Hour * refreshIntervalInHours) go func() { for range ticker.C { color.White("Updating users...") - slackUsers := GetSlackWorkspaceUsers(bot.Slack) - *bot.Users = *slackUsers + b.updateUsers() } }() } -func getReactionBot(options RegistrationOptions) ReactionBot { +func getReactionBot(options RegistrationOptions) reactionBot { slack := getSlackInstance(options) - slackUsers := GetSlackWorkspaceUsers(slack) + slackClient := newSlackClient(options) - bot := ReactionBot{ + b := reactionBot{ Slack: slack, + SlackClient: slackClient, RegisteredEmoji: options.RegisteredEmoji, - Users: slackUsers, + Users: &Users{}, } - return bot + b.updateUsers() + + return b } // New function to init the package func New(options RegistrationOptions) { bot := getReactionBot(options) - go bot.setUpdateTicker() - bot.RegisterSlackBot() + go bot.handleUpdateUsers() + defer bot.handleEvents() } diff --git a/reactionbot/reactions.go b/reactionbot/reactions.go index 1f596c3..78a643a 100644 --- a/reactionbot/reactions.go +++ b/reactionbot/reactions.go @@ -11,20 +11,17 @@ type Reaction struct { // RegisteredReactions are the registered reactions type RegisteredReactions map[string]Reaction -func (bot ReactionBot) getRegisteredReactionByEmoji(emoji string) (Reaction, bool) { - - registeredReaction, registeredReactionWasFound := bot.RegisteredEmoji[emoji] +func (b reactionBot) getRegisteredReactionByEmoji(emoji string) (Reaction, bool) { + registeredReaction, registeredReactionWasFound := b.RegisteredEmoji[emoji] return registeredReaction, registeredReactionWasFound } -// GetRegisteredReaction returns true if the reaction emoji has been registered -func (bot ReactionBot) GetRegisteredReaction(emoji string) Reaction { - reaction, _ := bot.getRegisteredReactionByEmoji(emoji) +func (b reactionBot) getRegisteredReaction(emoji string) Reaction { + reaction, _ := b.getRegisteredReactionByEmoji(emoji) return reaction } -// ReactionIsRegistered returns true if the reaction emoji has been registered -func (bot ReactionBot) ReactionIsRegistered(emoji string) bool { - _, reactionWasFound := bot.getRegisteredReactionByEmoji(emoji) +func (b reactionBot) reactionIsRegistered(emoji string) bool { + _, reactionWasFound := b.getRegisteredReactionByEmoji(emoji) return reactionWasFound } diff --git a/reactionbot/registration.go b/reactionbot/registration.go deleted file mode 100644 index 517c356..0000000 --- a/reactionbot/registration.go +++ /dev/null @@ -1,47 +0,0 @@ -package reactionbot - -import ( - "fmt" - "os" - - "github.com/fatih/color" - "github.com/slack-go/slack/slackevents" - "github.com/slack-go/slack/socketmode" -) - -// RegisterSlackBot is the function used to start the bot and listen for reactions -func (bot ReactionBot) RegisterSlackBot() { - - client := socketmode.New(bot.Slack) - - go func() { - for evt := range client.Events { - switch evt.Type { - case socketmode.EventTypeConnecting: - color.White("Connecting to Slack with Socket Mode...") - case socketmode.EventTypeConnectionError: - color.Red("Connection failed. Retrying later...") - case socketmode.EventTypeConnected: - color.Green("Connected to Slack with Socket Mode.") - case socketmode.EventTypeHello: - color.Green("Well hello there! Reaction Bot has finish starting up.") - case socketmode.EventTypeEventsAPI: - eventsAPIEvent, _ := evt.Data.(slackevents.EventsAPIEvent) - client.Ack(*evt.Request) - innerEvent := eventsAPIEvent.InnerEvent - if innerEvent.Type == slackevents.ReactionAdded { - reactionAddedEvent := innerEvent.Data.(*slackevents.ReactionAddedEvent) - reactionEmoji := reactionAddedEvent.Reaction - if bot.ReactionIsRegistered(reactionEmoji) { - bot.HandleMessageReaction(reactionAddedEvent) - } - } - - default: - fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) - } - } - }() - - client.Run() -} diff --git a/reactionbot/users.go b/reactionbot/users.go index 29a1213..adf2e54 100644 --- a/reactionbot/users.go +++ b/reactionbot/users.go @@ -1,54 +1,60 @@ package reactionbot import ( - "fmt" - - "github.com/slack-go/slack" + "github.com/fatih/color" ) -// SlackUser is any current user of this Slack workspace -type SlackUser struct { +type User struct { Username string FullName string DisplayName string ProfileImage string + IsBot bool } -// SlackUsers is a key/value ordered list of users by their UUID -type SlackUsers map[string]SlackUser +type Users map[string]User -func userIsInactive(user slack.User) bool { - // Don't include bots or deleted users in our list of users - return user.IsBot || - user.Deleted +func userIsInactive(user SlackUser) bool { + // Don't include Deleted users in our list (since it's unlikely users are + // reacting to messages that are from now-deleted users) + return user.Deleted } -// GetUserByUserID is a function to return a specific user given a user ID -func GetUserByUserID(users SlackUsers, userID string) SlackUser { - return users[userID] +func getUserByUserID(u Users, uid string) User { + return u[uid] } -// GetSlackWorkspaceUsers is a function to return all -// users of the workspace -func GetSlackWorkspaceUsers(slackInstance *slack.Client) *SlackUsers { - users, err := slackInstance.GetUsers() - if err != nil { - fmt.Printf("%s\n", err) +func getFormattedUser(user SlackUser) User { + return User{ + IsBot: user.IsBot, + Username: user.Name, + FullName: user.RealName, + DisplayName: user.Profile.DisplayNameNormalized, + ProfileImage: user.Profile.Image512, } +} - userDictionary := make(SlackUsers) - for _, user := range users { - // Don't include bots or deleted users in our list of users - if userIsInactive(user) { +func getFormattedUsers(users []SlackUser) Users { + userDictionary := make(Users) + for _, u := range users { + if userIsInactive(u) { continue } - userDictionary[user.ID] = SlackUser{ - Username: user.Name, - FullName: user.RealName, - DisplayName: user.Profile.DisplayNameNormalized, - ProfileImage: user.Profile.Image512, - } + userDictionary[u.ID] = getFormattedUser(u) + } + return userDictionary +} + +func (r reactionBot) getUsers() Users { + users, err := r.getSlackUsers() + if err != nil { + color.Red("Error getting users: %s\n", err) } - return &userDictionary + formattedUsers := getFormattedUsers(users) + return formattedUsers +} + +func (r *reactionBot) updateUsers() { + *r.Users = r.getUsers() }