diff --git a/core/configure.go b/core/configure.go index fd26a4b1..90609f07 100644 --- a/core/configure.go +++ b/core/configure.go @@ -47,23 +47,41 @@ func configureChatApplication(bot *models.Bot) { if err != nil { bot.Log.Warnf("Could not configure bot Name: %s", err.Error()) } + bot.Name = token if bot.ChatApplication != "" { switch strings.ToLower(bot.ChatApplication) { case "discord": - // Bot token from Discord + // Discord bot token token, err := utils.Substitute(bot.DiscordToken, map[string]string{}) if err != nil { bot.Log.Warnf("Could not set Discord Token: %s", err.Error()) bot.RunChat = false } + if token == "" { bot.Log.Warnf("Discord Token is empty: '%s'", token) bot.RunChat = false } + bot.DiscordToken = token + // Discord Server ID + // See https://support.discordapp.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- + serverID, err := utils.Substitute(bot.DiscordServerID, map[string]string{}) + if err != nil { + bot.Log.Warnf("Could not set Discord Server ID: %s", err.Error()) + bot.RunChat = false + } + + if serverID == "" { + bot.Log.Warnf("Discord Server ID is empty: '%s'", serverID) + bot.RunChat = false + } + + bot.DiscordServerID = serverID + case "slack": // Slack bot token token, err := utils.Substitute(bot.SlackToken, map[string]string{}) @@ -71,10 +89,12 @@ func configureChatApplication(bot *models.Bot) { bot.Log.Warnf("Could not set Slack Token: %s", err.Error()) bot.RunChat = false } + if token == "" { bot.Log.Warnf("Slack Token is empty: %s", token) bot.RunChat = false } + bot.SlackToken = token // Slack verification token @@ -82,8 +102,10 @@ func configureChatApplication(bot *models.Bot) { if err != nil { bot.Log.Warnf("Could not set Slack Verification Token: %s", err.Error()) bot.Log.Warn("Defaulting to use Slack RTM") + vToken = "" } + bot.SlackVerificationToken = vToken // Slack workspace token @@ -91,6 +113,7 @@ func configureChatApplication(bot *models.Bot) { if err != nil { bot.Log.Warnf("Could not set Slack Workspace Token: %s", err.Error()) } + bot.SlackWorkspaceToken = wsToken // Get Slack Events path @@ -100,6 +123,7 @@ func configureChatApplication(bot *models.Bot) { bot.Log.Warn("Defaulting to use Slack RTM") bot.SlackVerificationToken = "" } + bot.SlackEventsCallbackPath = eCallbackPath // Get Slack Interactive Components path @@ -108,10 +132,12 @@ func configureChatApplication(bot *models.Bot) { bot.Log.Errorf("Could not set Slack Interactive Components callback path: %s", err.Error()) bot.InteractiveComponents = false } + if iCallbackPath == "" { bot.Log.Warnf("Slack Interactive Components callback path is empty: %s", iCallbackPath) bot.InteractiveComponents = false } + bot.SlackInteractionsCallbackPath = iCallbackPath default: @@ -125,18 +151,22 @@ func validateRemoteSetup(bot *models.Bot) { if bot.ChatApplication != "" { bot.RunChat = true } + if bot.CLI { bot.RunCLI = true } + if !bot.CLI && bot.ChatApplication == "" { bot.Log.Fatalf("No chat_application specified and cli mode is not enabled. Exiting...") } + if bot.Scheduler { bot.RunScheduler = true if bot.CLI && bot.ChatApplication == "" { bot.Log.Warn("Scheduler does not support scheduled outputs to CLI mode") bot.RunScheduler = false } + if bot.ChatApplication == "" { bot.Log.Warn("Scheduler did not find any configured chat applications. Scheduler is closing") bot.RunScheduler = false diff --git a/core/configure_test.go b/core/configure_test.go index cc9469ae..2bf9ccfb 100644 --- a/core/configure_test.go +++ b/core/configure_test.go @@ -83,6 +83,12 @@ func Test_configureChatApplication(t *testing.T) { testBotSlackNoToken.ChatApplication = "slack" validateRemoteSetup(testBotSlackNoToken) + testBotBadName := new(models.Bot) + testBotBadName.CLI = true + testBotBadName.ChatApplication = "slack" + testBotBadName.Name = "${BOT_NAME}" + validateRemoteSetup(testBotBadName) + testBotSlackBadToken := new(models.Bot) testBotSlackBadToken.CLI = true testBotSlackBadToken.ChatApplication = "slack" @@ -150,12 +156,21 @@ func Test_configureChatApplication(t *testing.T) { testBotDiscordBadToken.DiscordToken = "${TOKEN}" validateRemoteSetup(testBotDiscordBadToken) - testBotDiscord := new(models.Bot) - testBotDiscord.CLI = true - testBotDiscord.ChatApplication = "discord" - testBotDiscord.DiscordToken = "${TEST_DISCORD_TOKEN}" + testBotDiscordServerID := new(models.Bot) + testBotDiscordServerID.CLI = true + testBotDiscordServerID.ChatApplication = "discord" + testBotDiscordServerID.DiscordToken = "${TEST_DISCORD_TOKEN}" + testBotDiscordServerID.DiscordServerID = "${TEST_DISCORD_SERVER_ID}" os.Setenv("TEST_DISCORD_TOKEN", "TESTTOKEN") - validateRemoteSetup(testBotDiscord) + os.Setenv("TEST_DISCORD_SERVER_ID", "TESTSERVERID") + validateRemoteSetup(testBotDiscordServerID) + + testBotDiscordBadServerID := new(models.Bot) + testBotDiscordBadServerID.CLI = true + testBotDiscordBadServerID.ChatApplication = "discord" + testBotDiscordBadServerID.DiscordToken = "${TEST_DISCORD_TOKEN}" + testBotDiscordBadServerID.DiscordServerID = "${TOKEN}" + validateRemoteSetup(testBotDiscordServerID) tests := []struct { name string @@ -166,6 +181,7 @@ func Test_configureChatApplication(t *testing.T) { {"Fail", args{bot: testBot}, false, false}, {"Fail - no chat_application not set", args{bot: testBotNoChat}, false, false}, {"Fail - Invalid value for chat_application", args{bot: testBotInvalidChat}, false, false}, + {"Bad Name", args{bot: testBotBadName}, false, false}, {"Slack - no token", args{bot: testBotSlackNoToken}, false, false}, {"Slack - bad token", args{bot: testBotSlackBadToken}, false, false}, {"Slack - bad verification token", args{bot: testBotSlackBadVerificationToken}, false, false}, @@ -176,7 +192,8 @@ func Test_configureChatApplication(t *testing.T) { {"Slack w/ bad events callback", args{bot: testBotSlackEventsCallbackFail}, true, false}, {"Discord - no token", args{bot: testBotDiscordNoToken}, false, false}, {"Discord - bad token", args{bot: testBotDiscordBadToken}, false, false}, - {"Discord", args{bot: testBotDiscord}, true, false}, + {"Discord w/ server id", args{bot: testBotDiscordServerID}, true, false}, + {"Discord w/ bad server id", args{bot: testBotDiscordBadServerID}, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -193,6 +210,7 @@ func Test_configureChatApplication(t *testing.T) { os.Unsetenv("TEST_SLACK_TOKEN") os.Unsetenv("TEST_DISCORD_TOKEN") + os.Unsetenv("TEST_DISCORD_SERVER_ID") os.Unsetenv("TEST_SLACK_INTERACTIONS_CALLBACK_PATH") os.Unsetenv("TEST_SLACK_INTERACTIONS_CALLBACK_PATH_FAIL") } diff --git a/models/bot.go b/models/bot.go index 80e8c2c3..6ca69910 100644 --- a/models/bot.go +++ b/models/bot.go @@ -13,6 +13,7 @@ type Bot struct { SlackEventsCallbackPath string `mapstructure:"slack_events_callback_path"` SlackInteractionsCallbackPath string `mapstructure:"slack_interactions_callback_path"` DiscordToken string `mapstructure:"discord_token"` + DiscordServerID string `mapstructure:"discord_server_id"` Users map[string]string `mapstructure:"slack_users"` UserGroups map[string]string `mapstructure:"slack_usergroups"` Rooms map[string]string `mapstructure:"slack_channels"` diff --git a/remote/discord/helper.go b/remote/discord/helper.go index b899c722..0c34ec25 100644 --- a/remote/discord/helper.go +++ b/remote/discord/helper.go @@ -23,27 +23,107 @@ func populateMessage(message models.Message, msgType models.MessageType, channel message.BotMentioned = mentioned message.ID = id - // if msgType != models.MsgTypeDirect { - // name, ok := findKey(bot.Rooms, channel) - // if !ok { - // bot.Log.Warnf("Could not find name of channel '%s'.", channel) - // } - // message.ChannelName = name - // } + if msgType != models.MsgTypeDirect { + name, ok := findKey(bot.Rooms, channel) + if !ok { + bot.Log.Warnf("Could not find name of channel '%s'.", channel) + } + + message.ChannelName = name + } message.Vars["_channel.id"] = channel - message.Vars["_channel.name"] = channel + message.Vars["_channel.name"] = message.ChannelName // Populate message user sender // These will be accessible on rules via ${_user.email}, etc if user != nil { // nil user implies a message from an api/bot (i.e. not an actual user) message.Vars["_user.email"] = user.Email - // message.Vars["_user.firstname"] = "" - // message.Vars["_user.lastname"] = "" message.Vars["_user.name"] = user.Username message.Vars["_user.id"] = user.ID } message.Debug = true + return message } + +// send - handles the sending logic of a message going to Discord +func send(dg *discordgo.Session, message models.Message, bot *models.Bot) { + if message.DirectMessageOnly { + err := handleDirectMessage(dg, message, bot) + if err != nil { + bot.Log.Errorf("Problem sending message: %s", err.Error()) + } + } else { + err := handleNonDirectMessage(dg, message, bot) + if err != nil { + bot.Log.Errorf("Problem sending message: %s", err.Error()) + } + } +} + +// handleDirectMessage - handle sending logic for direct messages +func handleDirectMessage(dg *discordgo.Session, message models.Message, bot *models.Bot) error { + // Is output to rooms set? + if len(message.OutputToRooms) > 0 { + bot.Log.Warn("You have specified 'direct_message_only' as 'true' and provided 'output_to_rooms'." + + " Messages will not be sent to listed rooms. If you want to send messages to these rooms," + + " please set 'direct_message_only' to 'false'.") + } + // Is output to users set? + if len(message.OutputToUsers) > 0 { + bot.Log.Warn("You have specified 'direct_message_only' as 'true' and provided 'output_to_users'." + + " Messages will not be sent to the listed users (other than you). If you want to send messages to other users," + + " please set 'direct_message_only' to 'false'.") + } + + userChannel, err := dg.UserChannelCreate(message.Vars["_user.id"]) + if err != nil { + return err + } + + _, err = dg.ChannelMessageSend(userChannel.ID, message.Output) + if err != nil { + return err + } + + return nil +} + +// handleNonDirectMessage - handle sending logic for non direct messages +func handleNonDirectMessage(dg *discordgo.Session, message models.Message, bot *models.Bot) error { + if len(message.OutputToUsers) == 0 && len(message.OutputToRooms) == 0 && len(message.Output) > 0 { + _, err := dg.ChannelMessageSend(message.ChannelID, message.Output) + if err != nil { + return err + } + } + + // message.OutputToRooms is already processed to be the translated IDs + // vs. the originally provided names + if len(message.OutputToRooms) > 0 { + for _, roomID := range message.OutputToRooms { + _, err := dg.ChannelMessageSend(roomID, message.Output) + if err != nil { + return err + } + } + } + + if len(message.OutputToUsers) > 0 { + for _, user := range message.OutputToUsers { + userChannel, err := dg.UserChannelCreate(bot.Users[user]) + if err != nil { + return err + } + + _, err = dg.ChannelMessageSend(userChannel.ID, message.Output) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/remote/discord/remote.go b/remote/discord/remote.go index 26cbbecd..c9e6a953 100644 --- a/remote/discord/remote.go +++ b/remote/discord/remote.go @@ -29,21 +29,28 @@ func (c *Client) new() *discordgo.Session { if err != nil { return nil } + return dg } // Reaction implementation to satisfy remote interface +// Note: Discord expects the actual unicode emoji, so you need to have that in the rule setup, ie. +// . +// reaction: 🔥 +// . func (c *Client) Reaction(message models.Message, rule models.Rule, bot *models.Bot) { if rule.RemoveReaction != "" { // Init api client dg := c.new() // Remove bot reaction from message if err := dg.MessageReactionRemove(message.ChannelID, message.ID, rule.RemoveReaction, "@me"); err != nil { - bot.Log.Errorf("Could not add reaction '%s'", err) + bot.Log.Errorf("Could not add reaction '%s'. Make sure to use actual emoji unicode characters.", err) return } + bot.Log.Debugf("Removed reaction '%s' for rule %s", rule.RemoveReaction, rule.Name) } + if rule.Reaction != "" { // Init api client dg := c.new() @@ -52,6 +59,7 @@ func (c *Client) Reaction(message models.Message, rule models.Rule, bot *models. bot.Log.Errorf("Could not add reaction '%s'", err) return } + bot.Log.Debugf("Added reaction '%s' for rule %s", rule.Reaction, rule.Name) } } @@ -63,6 +71,7 @@ func (c *Client) Read(inputMsgs chan<- models.Message, rules map[string]models.R bot.Log.Error("Failed to initialize Discord client") return } + err := dg.Open() if err != nil { bot.Log.Errorf("Failed to open connection to Discord server. Error: %s", err.Error()) @@ -77,8 +86,64 @@ func (c *Client) Read(inputMsgs chan<- models.Message, rules map[string]models.R bot.Log.Errorf("Failed to get bot name from Discord. Error: %s", err.Error()) return } + bot.Name = botuser.Username + foundGuild := false + + guilds := dg.State.Guilds + for _, g := range guilds { + if g.ID == bot.DiscordServerID { + foundGuild = true + break + } + } + + if !foundGuild { + bot.Log.Error("Unable to find server defined in 'discord_server_id'. Has the bot been added to the server?") + return + } + + rooms := make(map[string]string) + users := make(map[string]string) + groups := make(map[string]string) + + // populate rooms + gchans, err := dg.GuildChannels(bot.DiscordServerID) + if err != nil { + bot.Log.Debugf("Unable to get channels. Error: %v", err) + } + + for _, gchan := range gchans { + rooms[gchan.Name] = gchan.ID + } + + // populate users - 1000 is API limit + // TODO: paginate to *really* get all - would have to find highest ID + // from prev results and pass in as second param to .GuildMembers + gmembers, err := dg.GuildMembers(bot.DiscordServerID, "", 1000) + if err != nil { + bot.Log.Debugf("Unable to get users") + } + + for _, gmember := range gmembers { + users[gmember.User.Username] = gmember.User.ID + } + + // populate user groups + groles, err := dg.GuildRoles(bot.DiscordServerID) + if err != nil { + bot.Log.Debugf("Unable to get roles") + } + + for _, grole := range groles { + groups[grole.Name] = grole.ID + } + + bot.Rooms = rooms + bot.Users = users + bot.UserGroups = groups + // Register a callback for MessageCreate events dg.AddHandler(handleDiscordMessage(bot, inputMsgs)) } @@ -86,12 +151,13 @@ func (c *Client) Read(inputMsgs chan<- models.Message, rules map[string]models.R // Send implementation to satisfy remote interface func (c *Client) Send(message models.Message, bot *models.Bot) { dg := c.new() + + // Timestamp message + message.EndTime = models.MessageTimestamp() + switch message.Type { case models.MsgTypeDirect, models.MsgTypeChannel: - _, err := dg.ChannelMessageSend(message.ChannelID, message.Output) - if err != nil { - bot.Log.Errorf("Unable to send message: %v", err) - } + send(dg, message, bot) default: bot.Log.Errorf("Unable to send message of type %d", message.Type) } @@ -106,42 +172,40 @@ func (c *Client) InteractiveComponents(inputMsgs chan<- models.Message, message // message is created on any channel that the authenticated bot has access to func handleDiscordMessage(bot *models.Bot, inputMsgs chan<- models.Message) interface{} { return func(s *discordgo.Session, m *discordgo.MessageCreate) { - // Ignore all messages created by the bot itself + // Ignore all messages created by bots // This isn't required in this specific example but it's a good practice if m.Author.Bot { return } - // Ignore messages in public channels that don't mention the bot - ch, _ := s.Channel(m.ChannelID) - if ch.Type == discordgo.ChannelTypeGuildText { - botmention := false - for _, mention := range m.Mentions { - if mention.Username == bot.Name { - botmention = true - } - } - if !botmention { - return - } - } + // Process message message := models.NewMessage() switch m.Type { case discordgo.MessageTypeDefault: + var msgType models.MessageType + + ch, err := s.Channel(m.ChannelID) + if err != nil { + bot.Log.Errorf("Discord Remote: Failed to retrieve channel.") + } + t, err := m.Timestamp.Parse() if err != nil { bot.Log.Errorf("Discord Remote: Failed to parse message timestamp.") } + timestamp := strconv.FormatInt(t.Unix(), 10) - msgType := models.MsgTypeChannel + switch ch.Type { case discordgo.ChannelTypeDM: msgType = models.MsgTypeDirect case discordgo.ChannelTypeGuildText: - break + msgType = models.MsgTypeChannel default: + msgType = models.MsgTypeChannel bot.Log.Debugf("Discord Remote: read message from unsupported channel type '%d'. Defaulting to use channel type 0 ('GUILD_TEXT')", ch.Type) } + contents, mentioned := removeBotMention(m.Content, s.State.User.ID) message = populateMessage(message, msgType, m.ChannelID, m.Message.ID, contents, timestamp, mentioned, m.Author, bot) default: diff --git a/remote/discord/util.go b/remote/discord/util.go index fe8037cf..f3438e8d 100644 --- a/remote/discord/util.go +++ b/remote/discord/util.go @@ -11,14 +11,30 @@ Utility functions (does not use discord package) ================================================ */ +// findKey - find the key value in the map based on its value pair +func findKey(m map[string]string, value string) (key string, ok bool) { + for k, v := range m { + if v == value { + key = k + ok = true + + return + } + } + + return +} + // removeBotMention - parse out the preppended bot mention in a message func removeBotMention(contents, botID string) (string, bool) { - mention := fmt.Sprintf("<@%s>", botID) + mention := fmt.Sprintf("<@!%s>", botID) wasMentioned := false + if strings.HasPrefix(contents, mention) { contents = strings.Replace(contents, mention, "", -1) contents = strings.TrimSpace(contents) wasMentioned = true } + return contents, wasMentioned } diff --git a/remote/slack/helper.go b/remote/slack/helper.go index 45f0130b..84ae21bf 100644 --- a/remote/slack/helper.go +++ b/remote/slack/helper.go @@ -294,13 +294,13 @@ func handleDirectMessage(api *slack.Client, message models.Message, bot *models. if len(message.OutputToRooms) > 0 { bot.Log.Warn("You have specified 'direct_message_only' as 'true' and provided 'output_to_rooms'." + " Messages will not be sent to listed rooms. If you want to send messages to these rooms," + - " please set 'direct_message_ony' to 'false'.") + " please set 'direct_message_only' to 'false'.") } // Is output to users set? if len(message.OutputToUsers) > 0 { bot.Log.Warn("You have specified 'direct_message_only' as 'true' and provided 'output_to_users'." + " Messages will not be sent to the listed users (other than you). If you want to send messages to other users," + - " please set 'direct_message_ony' to 'false'.") + " please set 'direct_message_only' to 'false'.") } // Respond back to user via direct message return sendDirectMessage(api, message.Vars["_user.id"], message) diff --git a/remote/slack/remote.go b/remote/slack/remote.go index a3b21efb..0ca8324f 100644 --- a/remote/slack/remote.go +++ b/remote/slack/remote.go @@ -124,7 +124,7 @@ func (c *Client) Send(message models.Message, bot *models.Bot) { case models.MsgTypeDirect, models.MsgTypeChannel, models.MsgTypePrivateChannel: send(api, message, bot) default: - bot.Log.Warn("Received unknown message type - no message to send") + bot.Log.Warn("Received unknown message type - no message to send") } } diff --git a/utils/access_check.go b/utils/access_check.go index b4a14894..daead985 100644 --- a/utils/access_check.go +++ b/utils/access_check.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/bwmarrin/discordgo" "github.com/nlopes/slack" "github.com/target/flottbot/models" ) @@ -31,8 +32,9 @@ func CanTrigger(currentUserName string, currentUserID string, rule models.Rule, if err != nil { return false } + if isIgnored { - bot.Log.Debugf("'%s' is part of any group in ignore_usergroups: %s", currentUserName, strings.Join(rule.IgnoreUserGroups, ", ")) + bot.Log.Debugf("'%s' is part of a group in ignore_usergroups: %s", currentUserName, strings.Join(rule.IgnoreUserGroups, ", ")) return false } @@ -50,8 +52,8 @@ func CanTrigger(currentUserName string, currentUserID string, rule models.Rule, } // check if they are part of the allow users ids list - for _, userId := range rule.AllowUserIds { - if userId == currentUserID { + for _, userID := range rule.AllowUserIds { + if userID == currentUserID { canRunRule = true break } @@ -65,6 +67,7 @@ func CanTrigger(currentUserName string, currentUserID string, rule models.Rule, if err != nil { return false } + canRunRule = isAllowed } @@ -77,7 +80,7 @@ func CanTrigger(currentUserName string, currentUserID string, rule models.Rule, bot.Log.Debugf("'%s' is not part of allow_userids: %s", currentUserID, strings.Join(rule.AllowUserIds, ", ")) } - if len(rule.AllowUserGroups) > 0 { + if len(rule.AllowUserGroups) > 0 { bot.Log.Debugf("'%s' is not part of any groups in allow_usergroups: %s", currentUserName, strings.Join(rule.AllowUserGroups, ", ")) } } @@ -96,18 +99,40 @@ func isMemberOfGroup(currentUserID string, userGroups []string, bot *models.Bot) capp := strings.ToLower(bot.ChatApplication) switch capp { case "discord": - bot.Log.Error("Discord is currently not supported for validating user permissions on rules") + var usr *discordgo.Member + + dg, err := discordgo.New("Bot " + bot.DiscordToken) + if err != nil { + return false, err + } + + usr, err = dg.GuildMember(bot.DiscordServerID, currentUserID) + if err != nil { + bot.Log.Debugf("Error while searching for user. Error: %v", err) + return false, nil + } + + for _, group := range userGroups { + for _, uGroup := range usr.Roles { + if strings.EqualFold(bot.UserGroups[group], uGroup) { + return true, nil + } + } + } + return false, nil case "slack": if bot.SlackWorkspaceToken == "" { bot.Log.Debugf("Limiting to usergroups only works if you register " + "your bot as an app with Slack and set the 'slack_workspace_token' property. " + "Restricting access to rule. Unset 'allow_usergroups' and/or 'ignore_usergroups', or set 'slack_workspace_token'.") + return false, fmt.Errorf("slack_workspace_token not supplied - restricting access") } // Check if we are restricting by usergroup if bot.SlackWorkspaceToken != "" { wsAPI := slack.New(bot.SlackWorkspaceToken) + for _, usergroupName := range userGroups { // Get the ID of the group from the usergroups the bot is aware of for knownUserGroupName, knownUserGroupID := range bot.UserGroups { @@ -123,12 +148,15 @@ func isMemberOfGroup(currentUserID string, userGroups []string, bot *models.Bot) return true, nil } } + break } } } + wsAPI = nil } + return false, nil default: bot.Log.Errorf("Chat application %s is not supported", capp)