diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c69f4c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ASB + +A work in progress anti spam bot for discord. \ No newline at end of file diff --git a/go.mod b/go.mod index 1127c60..3d597e8 100644 --- a/go.mod +++ b/go.mod @@ -41,4 +41,5 @@ require ( golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.20.0 // indirect + mvdan.cc/xurls/v2 v2.5.0 // indirect ) diff --git a/go.sum b/go.sum index b870382..1c9088f 100644 --- a/go.sum +++ b/go.sum @@ -160,3 +160,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= +mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/internal/bot/handlers/message.go b/internal/bot/handlers/message.go index a85f086..89a115a 100644 --- a/internal/bot/handlers/message.go +++ b/internal/bot/handlers/message.go @@ -1,6 +1,15 @@ package handlers -import "github.com/bwmarrin/discordgo" +import ( + "crypto/md5" + "fmt" + "regexp" + "strings" + + "github.com/FM1337/ASB/internal/models" + "github.com/bwmarrin/discordgo" + "mvdan.cc/xurls/v2" +) func (h *Handlers) MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { // Ignore all messages created by the bot itself @@ -14,4 +23,105 @@ func (h *Handlers) MessageCreate(s *discordgo.Session, m *discordgo.MessageCreat return } + // Check if the bot is enabled for the server or if it should ignore the message + + if !h.data.Enabled(m.GuildID) || !h.data.ShouldIgnore(m.GuildID, m.ChannelID, m.Author.ID, m.Member.Roles) { + // Do nothing + return + } + + // used to determine the level of suspiciousness the message/author is at. + susLevel := 0 + // if this gets set to true, we stop all remaining checks and take an action + instaAction := false + + // grab the config + cfg := h.data.GetConfiguration(m.GuildID) + + // If enabled, messages containing links should increase the sus level + if cfg.FlagLinks && xurls.Relaxed().MatchString(m.Content) { + susLevel++ + } + + // we should check for blacklisted words and increase if any are found + if h.data.CheckForBlacklistedContent(m.Content, m.GuildID) { + susLevel++ + } + + if susLevel == 0 { + // At this point if the level is still 0, it's more than likely not spam so we can bail here + return + } + + // if the sus level isn't 0 and ratelimit is turned on, let's start that magic + + if cfg.EnforceRatelimit { + // hash the message + hash := fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(m.Content)))) + seenBefore, count := h.data.MessageSeenBefore(m.GuildID, m.Author.ID, hash) + if count+1 >= cfg.RateLimitCount { + // User has hit the limit, we need to action now + instaAction = true + } else if seenBefore { + // okay we've seen this messag before from this user in the server within the + // rate limit time, let's reset the timer for them and increment their count + } else { + // first time seeing this message, let's create a new entry for them. + } + } + + if instaAction { + // Take action now. + doAction(s, m, &cfg) + return + } + + if cfg.CheckInvites { + // check for an invite + hasInvite, inviteCode := grabDiscordLink(m.Content) + if hasInvite { + // get information about the invite + invite, err := s.Invite(inviteCode) + if err != nil { + // error handling + return + } + // increase the sus level by 1 just for containing an invite + susLevel++ + + if h.data.CheckForBlacklistedContent(invite.Guild.Name, m.ChannelID) { + // Blackedlisted content found, sus goes up by 1 + susLevel++ + } + } + } + +} + +func grabDiscordLink(msg string) (bool, string) { + r, _ := regexp.Compile("discord.gg/[a-zA-z0-9]{1,10}") + + match := r.FindString(strings.ToLower(msg)) + + split := strings.Split(match, "/") + + if len(split) == 2 { + match = split[1] + } + + return match != "", match +} + +func doAction(s *discordgo.Session, m *discordgo.MessageCreate, cfg *models.ServerConfig) { + if cfg.Ban { + // Note until discordgo implements the ban delete message seconds query param, we'll just default to 1 day + // Easiest action to take, we ban and clear + + err := s.GuildBanCreateWithReason(m.ChannelID, m.Author.ID, "User was flagged as a spammer by security bot", 1) + if err != nil { + // likely a permission error, log it anyway (TODO logging) + } + + return + } } diff --git a/internal/bot/memory/data.go b/internal/bot/memory/data.go index 3c0971c..f1a366e 100644 --- a/internal/bot/memory/data.go +++ b/internal/bot/memory/data.go @@ -2,6 +2,7 @@ package memory import ( "context" + "fmt" "slices" "strings" @@ -80,6 +81,7 @@ func LoadData(db *ent.Client) (*Data, error) { ExcludedChannels: config.ExcludedChannels, ExcludedRoles: config.ExcludedRoles, ExcluedUsers: config.ExcludedUsers, + RateLimitCount: config.RatelimitMessage, RateLimitTime: config.RatelimitTime, TimeoutTime: config.TimeoutTime, BanMessageDeleteTime: config.BanDeleteMessageTime, @@ -148,6 +150,7 @@ func (d *Data) AddServer(serverId, ownerId string) (error, error) { ExcludedChannels: config.ExcludedChannels, ExcludedRoles: config.ExcludedRoles, ExcluedUsers: config.ExcludedUsers, + RateLimitCount: config.RatelimitMessage, RateLimitTime: config.RatelimitTime, TimeoutTime: config.TimeoutTime, BanMessageDeleteTime: config.BanDeleteMessageTime, @@ -165,6 +168,95 @@ func (d *Data) ServerExists(serverId string) bool { return ok } +func (d *Data) EnableServer(serverId string) (bool, error) { + if !d.ServerExists(serverId) { + return false, fmt.Errorf("your server doesn't exist in the bot's memory") + } + + // check to see if the bot is already enabled + + if d.enabled[serverId] { + return false, fmt.Errorf("bot already enabled") + } + + // grab the config + cfg := d.GetConfiguration(serverId) + + // this is to determine the highest possible level of sus that can + // be detected based on enabled checks + highestPossibleSusLevel := 0 + + hasBlacklist := len(d.blacklist[serverId]) > 0 + + if hasBlacklist { + highestPossibleSusLevel++ + } + + if cfg.CheckLinks { + highestPossibleSusLevel++ + } + + if cfg.CheckInvites { + highestPossibleSusLevel++ + + // if there's a blacklist, there's a possibility of the invite server name being in there. + if hasBlacklist { + highestPossibleSusLevel++ + } + } + + if cfg.EnforceRatelimit { + highestPossibleSusLevel++ + } + + if cfg.FlagLinks { + highestPossibleSusLevel++ + } + + // We need at 3, if not then the bot can't reliably flag spammers + + if highestPossibleSusLevel < 3 { + return false, fmt.Errorf("security configured too low, high chance of false flagging or missing actual spammers") + } + + return d.changeServerStatus(serverId, true) +} + +func (d *Data) DisableServer(serverId string) (bool, error) { + if !d.ServerExists(serverId) { + return false, fmt.Errorf("your server doesn't exist in the bot's memory") + } + + // check to see if the bot is already disabled + if !d.enabled[serverId] { + return false, fmt.Errorf("bot already disabled") + } + + return d.changeServerStatus(serverId, false) +} + +func (d *Data) changeServerStatus(serverId string, status bool) (bool, error) { + srv, err := d.db.Server.Query().Where(server.ServerID(serverId)).First(context.Background()) + if err != nil { + if ent.IsNotFound(err) { + return false, fmt.Errorf("server missing from database") + } + // TODO log the error to the error channel/probably sentry but don't send the exposed error + return false, fmt.Errorf("unexpected server error occurred while fetching server") + } + + _, err = srv.Update().SetEnabled(status).Save(context.TODO()) + + if err != nil { + return false, fmt.Errorf("unexpected server error occurred while saving server enabled status") + } + + // update memory db + d.enabled[serverId] = status + + return true, nil +} + func (d *Data) UpdateConfiguration(serverId string, config models.ServerConfig) {} func (d *Data) GetConfiguration(serverId string) models.ServerConfig { @@ -195,6 +287,34 @@ func (d *Data) Enabled(serverId string) bool { return d.enabled[serverId] } +func (d *Data) ShouldIgnore(serverId, channelId, userId string, roleIds []string) bool { + cfg := d.configuration[serverId] + + // Check if the message is in a channel or is a user for a server that we should ignore + if slices.Contains(cfg.ExcludedChannels, channelId) || slices.Contains(cfg.ExcluedUsers, userId) { + return true + } + + // check for er + return slices.ContainsFunc(cfg.ExcludedRoles, func(roleId string) bool { + return slices.Contains(roleIds, roleId) + }) + +} + +func (d *Data) MessageSeenBefore(serverId, userId, hash string) (bool, int) { + cooldowns := d.cooldown[serverId] + index := slices.IndexFunc(cooldowns, func(c models.Cooldown) bool { + return c.HashId == hash && c.UserId == userId + }) + + if index == -1 { + return false, 0 + } + + return true, cooldowns[index].Count +} + // Cleans up unused data (such as unused words) TODO func (d *Data) cleanup() { } diff --git a/internal/database/schema/server_config.go b/internal/database/schema/server_config.go index c2c4591..68fb7bf 100644 --- a/internal/database/schema/server_config.go +++ b/internal/database/schema/server_config.go @@ -35,7 +35,7 @@ func (ServerConfig) Fields() []ent.Field { field.Int("ratelimit_message").Default(3).Comment("The amount of times the same message can be sent within the rate limit period before being considered spam"), - field.Enum("ratelimit_time").Values().Values("30s", "1m", "2m", "3m", "4m", "5m").Default("5m").Comment("The ratelimit cooldown time, message tracking will be reset after this time period"), + field.Enum("ratelimit_time").Values().Values("30s", "1m", "2m", "3m", "4m", "5m").Default("2m").Comment("The ratelimit cooldown time, message tracking will be reset after this time period"), field.Enum("timeout_time").Values().Values("60s", "5m", "10m", "1h", "1d", "1w").Default("1h").Comment("The discord timeout time assigned to a spammer"), field.Enum("ban_delete_message_time").Values("1h", "6h", "12h", "1d", "3d", "1w").Default("1h").Comment("The discord time to remove messages sent by a spammer"), } diff --git a/internal/models/data.go b/internal/models/data.go index 6366569..72763fd 100644 --- a/internal/models/data.go +++ b/internal/models/data.go @@ -26,6 +26,9 @@ type ServerConfig struct { ExcludedRoles []string ExcluedUsers []string + // Ratelimit limit + RateLimitCount int + /** pre defined values */ RateLimitTime serverconfig.RatelimitTime TimeoutTime serverconfig.TimeoutTime @@ -33,8 +36,9 @@ type ServerConfig struct { } type Cooldown struct { - UserId string - HashId string - Count int - ResetAt time.Time + UserId string + HashId string + MessageIds []string + Count int + ResetAt time.Time }