diff --git a/src/internal/bot/auto_scrubber.go b/src/internal/bot/auto_scrubber.go new file mode 100644 index 0000000..0406650 --- /dev/null +++ b/src/internal/bot/auto_scrubber.go @@ -0,0 +1,110 @@ +package bot + +import ( + "log/slog" + "time" +) + +// scrubberInterval - How often we check +const scrubberInterval = 1 * time.Minute + +var ( + // scrubbers - Map of auto scrubbers to monitor + scrubbers map[string]map[string]time.Duration + + // Chan to use on shutdown + scrubberStop = make(chan struct{}) +) + +// initScrubber - Loads the scrubber configs from the DB +func initScrubber() error { + // Load config + allScrubbers, err := b.Db.GetAllAutoScrubbers() + if err != nil { + return err + } + + // map[GuildID][UserID]Interval + scrubbers = make(map[string]map[string]time.Duration) + for _, scrubber := range allScrubbers { + if _, ok := scrubbers[scrubber.GuildID]; !ok { + scrubbers[scrubber.GuildID] = make(map[string]time.Duration) + } + + scrubbers[scrubber.GuildID][scrubber.UserID] = scrubber.Duration + } + + runScrubber() + + return nil +} + +// runScrubber - Start the auto scrubber system +func runScrubber() { + ticker := time.NewTicker(scrubberInterval) + go func() { + for { + select { + case <-ticker.C: + // Loop through all scrubbers, clone for funsies + tmpScrub := scrubbers + for guildID, users := range tmpScrub { + for userID, interval := range users { + emojis, err := b.Db.GetAllEmojisForUser(guildID, userID) + if err != nil { + slog.Error("Error getting recent emojis for user", "guild_id", guildID, "user_id", userID, "err", err) + continue + } + + // If older creation time + interval is before now, remove it + for _, e := range emojis { + if e.Timestamp.Add(interval).Before(time.Now()) { + // Ignore errors here as it's likely bad data + err := b.DiscordSession.MessageReactionRemove(e.ChannelID, e.MessageID, e.EmojiID, e.UserID) + if err != nil { + slog.Error("Error removing emoji reaction", "err", err, "emoji", e.EmojiID, "user", e.UserID) + continue + } + + // We care if we can't delete from our DB.. + err = b.Db.DeleteEmojiUsageById(e.ID) + if err != nil { + slog.Error("Error deleting emoji usage", "err", err, "emoji", e) + continue + } + } + } + } + } + // Handle shutdown + case <-scrubberStop: + ticker.Stop() + return + } + } + }() +} + +// startScrubbingUser - Start an auto scrubber for a user in a guild +func startScrubbingUser(guildID string, userID string, interval time.Duration) error { + err := b.Db.AddAutoScrubber(guildID, userID, interval) + if err != nil { + slog.Error("Failed to add auto scrubber", "err", err) + return err + } + + // Add to map + if _, ok := scrubbers[guildID]; !ok { + scrubbers[guildID] = make(map[string]time.Duration) + } + scrubbers[guildID][userID] = interval + + return nil +} + +// stopScrubbingUser - Stop an auto scrubber for a user in a guild +func stopScrubbingUser(guildID string, userID string) error { + // Remove from instant + delete(scrubbers[guildID], userID) + return b.Db.RemoveAutoScrubber(guildID, userID) +} diff --git a/src/internal/bot/commands.go b/src/internal/bot/commands.go index 3f1c784..f25f903 100644 --- a/src/internal/bot/commands.go +++ b/src/internal/bot/commands.go @@ -5,6 +5,7 @@ import ( "log/slog" "sort" "strings" + "time" "github.com/bwmarrin/discordgo" ) @@ -46,8 +47,8 @@ var ( }, }, { - Name: "purge-recent-emojis", - Description: "Purges recent emojis", + Name: "add-auto-scrubber", + Description: "Auto Scrub Emoijis after a set period", DefaultMemberPermissions: &defaultRunCommandPermissions, Options: []*discordgo.ApplicationCommandOption{ { @@ -58,8 +59,21 @@ var ( }, { Type: discordgo.ApplicationCommandOptionInteger, - Name: "hours", - Description: "Hours to purge", + Name: "minutes", + Description: "Every X minutes", + Required: true, + }, + }, + }, + { + Name: "remove-auto-scrubber", + Description: "Stop autoscrubbing emojis after a set period", + DefaultMemberPermissions: &defaultRunCommandPermissions, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "Select user", Required: true, }, }, @@ -67,9 +81,10 @@ var ( } commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "show-top-emojis": showTopEmojis, - "show-top-users": showTopUsers, - "purge-recent-emojis": purgeRecentEmojis, + "show-top-emojis": showTopEmojis, + "show-top-users": showTopUsers, + "add-auto-scrubber": addAutoScrubber, + "remove-auto-scrubber": removeAutoScrubber, } ) @@ -221,8 +236,8 @@ func showTopUsers(s *discordgo.Session, i *discordgo.InteractionCreate) { }) } -// purgeRecentEmojis - Purges recent emojis for a user -func purgeRecentEmojis(s *discordgo.Session, i *discordgo.InteractionCreate) { +// addAutoScrubber - Scrubs emojis after a set period +func addAutoScrubber(s *discordgo.Session, i *discordgo.InteractionCreate) { // Access options in the order provided by the user. options := i.ApplicationCommandData().Options optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) @@ -245,52 +260,86 @@ func purgeRecentEmojis(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - hours := int64(24) - if opt, ok := optionMap["hours"]; ok { - hours = opt.IntValue() + var minutes int64 + if opt, ok := optionMap["minutes"]; ok { + minutes = opt.IntValue() } else { - slog.Error("Invalid hours option provided") + slog.Error("Invalid time option provided") s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: "No hours specified", + Content: "No minutes specified", AllowedMentions: &discordgo.MessageAllowedMentions{}, }, }) return } - emojis, err := b.Db.GetRecentEmojisForUser(i.GuildID, user.ID, hours) + dur := time.Duration(minutes) * time.Minute + err := startScrubbingUser(i.GuildID, user.ID, dur) if err != nil { - slog.Error("Error getting recent emojis for user", "err", err) + slog.Error("Error starting auto scrubber", "err", err, "guild_id", i.GuildID, "user_id", user.ID, "duration", dur) + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Error auto scrubbing user %s", user.Username), + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) + return } - x := 0 - for _, emoji := range emojis { - emojiID := emoji.EmojiID - if emojiID == "" { - emojiID = emoji.EmojiName - } - - err := s.MessageReactionRemove(emoji.ChannelID, emoji.MessageID, emojiID, emoji.UserID) - if err != nil { - slog.Error("Error removing emoji reaction", "err", err, "emoji", emoji.EmojiID, "user", user.ID) - continue - } - - err = b.Db.DeleteEmojiUsage(i.GuildID, emoji.ChannelID, emoji.MessageID, emoji.UserID, emoji.EmojiID) - if err != nil { - slog.Error("Error deleting emoji usage", "err", err) - continue - } - x++ - } - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("Purged %d emojis for user %s", x, user.Username), + Content: fmt.Sprintf("Will remove %s's emojis every %d minutes", user.Username, minutes), + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) +} + +// removeAutoScrubber - Stops scrubbing emojis +func removeAutoScrubber(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Access options in the order provided by the user. + options := i.ApplicationCommandData().Options + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + user := &discordgo.User{} + if opt, ok := optionMap["user"]; ok { + user = opt.UserValue(s) + } else { + slog.Error("Invalid user option provided") + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "No user specified", + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) + return + } + + err := stopScrubbingUser(i.GuildID, user.ID) + if err != nil { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Error stopping scrub on %s: %+v", user.Username, err.Error()), + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) + + return + } + + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Stopped scrubbing %s's emojis", user.Username), AllowedMentions: &discordgo.MessageAllowedMentions{}, }, }) diff --git a/src/internal/bot/main.go b/src/internal/bot/main.go index cf49644..af8a0bb 100644 --- a/src/internal/bot/main.go +++ b/src/internal/bot/main.go @@ -78,6 +78,9 @@ func (bot *Bot) Start() error { // Deregister any commands we created bot.DeregisterCommands() + // Stop scrubbin! + close(scrubberStop) + return nil } diff --git a/src/internal/db/auto_scrubber.go b/src/internal/db/auto_scrubber.go new file mode 100644 index 0000000..6e8b1a6 --- /dev/null +++ b/src/internal/db/auto_scrubber.go @@ -0,0 +1,51 @@ +package db + +import ( + "time" +) + +type AutoScrubber struct { + GuildID string `json:"guild_id"` + UserID string `json:"user_id"` + Duration time.Duration `json:"duration"` +} + +// AddAutoScrubber - Add an auto scrubber for a guild/user +func (db *Database) AddAutoScrubber(guildID, userID string, duration time.Duration) error { + _, err := db.db.Exec( + "INSERT IGNORE INTO `auto_scrubber` (`guild_id`, `user_id`, `duration`) VALUES (?,?,?)", + guildID, userID, duration, + ) + + return err +} + +// RemoveAutoScrubber - Delete for guild/channel/message/user +func (db *Database) RemoveAutoScrubber(guildID, userID string) error { + _, err := db.db.Exec( + "DELETE FROM `auto_scrubber` WHERE `guild_id` = ? AND `user_id` = ?", + guildID, userID, + ) + + return err +} + +// DeleteEmojiAll - Delete for whole message +func (db *Database) GetAllAutoScrubbers() ([]AutoScrubber, error) { + data := make([]AutoScrubber, 0) + row, err := db.db.Query("SELECT guild_id, user_id, duration from `auto_scrubber`") + if err != nil { + return data, err + } + + defer row.Close() + for row.Next() { + var guildID string + var userID string + var duration time.Duration + row.Scan(&guildID, &userID, &duration) + data = append(data, AutoScrubber{GuildID: guildID, UserID: userID, Duration: duration}) + } + + return data, nil +} diff --git a/src/internal/db/database.go b/src/internal/db/database.go index 26a78c9..1b79096 100644 --- a/src/internal/db/database.go +++ b/src/internal/db/database.go @@ -36,7 +36,6 @@ func InitDb() (*Database, error) { // runMigrations - Run migrations for connection func (db *Database) runMigrations() (*Database, error) { - // Hacked up af - Rerunnable _, err := db.db.Exec("CREATE TABLE IF NOT EXISTS `emoji_usage` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + "`guild_id` TEXT, " + @@ -61,6 +60,21 @@ func (db *Database) runMigrations() (*Database, error) { return db, err } + _, err = db.db.Exec("CREATE TABLE IF NOT EXISTS `auto_scrubber` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`guild_id` TEXT, " + + "`user_id` TEXT, " + + "`duration` INT " + + ")") + if err != nil { + return db, err + } + + _, err = db.db.Exec("CREATE INDEX IF NOT EXISTS `idx_auto_scrubber_guild_user` ON `auto_scrubber` (`guild_id`, `user_id`)") + if err != nil { + return db, err + } + // emojitest := map[string]string{ // "pepe_analyze": "579431592624390147", // "kekw": "1317987954102112347", diff --git a/src/internal/db/emoji_usage.go b/src/internal/db/emoji_usage.go index 86248fb..c74a7ab 100644 --- a/src/internal/db/emoji_usage.go +++ b/src/internal/db/emoji_usage.go @@ -1,6 +1,9 @@ package db -import "fmt" +import ( + "fmt" + "time" +) type EmojiMap struct { EmojiID string @@ -9,13 +12,14 @@ type EmojiMap struct { } type EmojiUsage struct { + ID int64 GuildID string ChannelID string MessageID string UserID string EmojiID string EmojiName string - Timestamp string + Timestamp time.Time } // LogEmojiUsage - Log usage @@ -38,6 +42,16 @@ func (db *Database) DeleteEmojiUsage(guildID, channelID, messageID, userID, emoj return err } +// DeleteEmojiUsageById - Delete for guild/channel/message/user +func (db *Database) DeleteEmojiUsageById(id int64) error { + _, err := db.db.Exec( + "DELETE FROM `emoji_usage` WHERE `id` = ?", + id, + ) + + return err +} + // DeleteEmojiAll - Delete for whole message func (db *Database) DeleteEmojiAll(guildID, channelID, messageID string) error { _, err := db.db.Exec( @@ -179,3 +193,27 @@ func (db *Database) GetRecentEmojisForUser(guildID string, userID string, hours return data, nil } + +// GetAllEmojisForUser - Get all emojis used by user map[] +func (db *Database) GetAllEmojisForUser(guildID string, userID string) ([]EmojiUsage, error) { + var data []EmojiUsage + row, err := db.db.Query( + "SELECT guild_id, channel_id, message_id, user_id, emoji_id, emoji_name, timestamp "+ + "FROM `emoji_usage` WHERE `guild_id` = ? AND `user_id` = ?", + guildID, + userID, + ) + + if err != nil { + return data, err + } + + defer row.Close() + for row.Next() { + usage := EmojiUsage{} + row.Scan(&usage.GuildID, &usage.ChannelID, &usage.MessageID, &usage.UserID, &usage.EmojiID, &usage.EmojiName, &usage.Timestamp) + data = append(data, usage) + } + + return data, nil +}