From fefbe2176bc6547022f7f1ea9bf8026399b26924 Mon Sep 17 00:00:00 2001 From: Vegard Berg Date: Sat, 16 Nov 2024 02:11:30 +0100 Subject: [PATCH 1/4] feat: Prune and Prune dry run command --- commands/prune.go | 258 +++++++++++++++++++++++++++++++++++++++++ globals/globals.go | 5 + go.mod | 2 +- listeners/audit_log.go | 9 ++ main.go | 5 + utils/users.go | 50 ++++++++ utils/utils.go | 46 ++++++++ 7 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 commands/prune.go create mode 100644 globals/globals.go create mode 100644 utils/users.go diff --git a/commands/prune.go b/commands/prune.go new file mode 100644 index 0000000..c6ec17c --- /dev/null +++ b/commands/prune.go @@ -0,0 +1,258 @@ +package commands + +import ( + "fmt" + "log/slog" + "time" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgo/rest" + "github.com/disgoorg/json" + + "github.com/myrkvi/heimdallr/globals" + "github.com/myrkvi/heimdallr/model" + "github.com/myrkvi/heimdallr/utils" +) + +var PruneDryRunCommand = discord.SlashCommandCreate{ + Name: "prune-pending-members-dry-run", + NameLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "fjern-ventende-medlemmer-dry-run", + }, + Description: "Prune members.", + DescriptionLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "Fjern medlemmer.", + }, + + Contexts: []discord.InteractionContextType{ + discord.InteractionContextTypeGuild, + }, + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + }, + + DefaultMemberPermissions: json.NewNullablePtr(discord.PermissionManageGuild), + + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionInt{ + Name: "days", + NameLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "dager", + }, + Description: "The number of days to prune members for.", + DescriptionLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "Antall dager å fjerne medlemmer for.", + }, + Required: true, + + MinValue: utils.Ref(0), + MaxValue: utils.Ref(90), + }, + }, +} + +func PruneDryRunHandler(e *handler.CommandEvent) error { + if e.GuildID() == nil { + return ErrEventNoGuildID + } + days := e.SlashCommandInteractionData().Int("days") + + guildSettings, err := model.GetGuildSettings(*e.GuildID()) + if err != nil { + _ = e.CreateMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: could not get guild settings."). + Build()) + return err + } + + if guildSettings.GatekeepPendingRole == 0 { + return e.CreateMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: no pending role set. This command will only prune pending members."). + Build()) + } + + _ = e.DeferCreateMessage(true) + + prunableMembers, err := getPrunableMembers(e, days, guildSettings) + if err != nil { + _, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: could not get member list."). + Build()) + return err + } + + numKicked := len(prunableMembers) + + adminMessage := fmt.Sprintf("Dry run: pruned %d members.\n\nMembers:\n", numKicked) + + for _, member := range prunableMembers { + if member == nil { + continue + } + + adminMessage += fmt.Sprintf("-# %s (%s)\n", member.User.Username, member.User.ID) + } + + _, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent(adminMessage). + Build()) + return err +} + +var PruneCommand = discord.SlashCommandCreate{ + Name: "prune-pending-members", + NameLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "fjern-ventende-medlemmer", + }, + Description: "Prune members.", + DescriptionLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "Fjern medlemmer.", + }, + + Contexts: []discord.InteractionContextType{ + discord.InteractionContextTypeGuild, + }, + IntegrationTypes: []discord.ApplicationIntegrationType{ + discord.ApplicationIntegrationTypeGuildInstall, + }, + + DefaultMemberPermissions: json.NewNullablePtr(discord.PermissionManageGuild), + + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionInt{ + Name: "days", + NameLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "dager", + }, + Description: "The number of days to prune members for.", + DescriptionLocalizations: map[discord.Locale]string{ + discord.LocaleNorwegian: "Antall dager å fjerne medlemmer for.", + }, + Required: true, + + MinValue: utils.Ref(0), + MaxValue: utils.Ref(90), + }, + }, +} + +func PruneHandler(e *handler.CommandEvent) error { + if e.GuildID() == nil { + return ErrEventNoGuildID + } + days := e.SlashCommandInteractionData().Int("days") + + guildSettings, err := model.GetGuildSettings(*e.GuildID()) + if err != nil { + _ = e.CreateMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: could not get guild settings."). + Build()) + return err + } + + if guildSettings.GatekeepPendingRole == 0 { + return e.CreateMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: no pending role set. This command will only prune pending members."). + Build()) + } + + _ = e.DeferCreateMessage(true) + + var kickedMembers []*discord.Member + + prunableMembers, err := getPrunableMembers(e, days, guildSettings) + if err != nil { + _, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: could not get member list."). + Build()) + return err + } + + for _, member := range prunableMembers { + + globals.ExcludedFromModKickLog[member.User.ID] = struct{}{} + kickedMembers = append(kickedMembers, member) + + err = e.Client().Rest().RemoveMember(*e.GuildID(), member.User.ID, + rest.WithReason( + fmt.Sprintf("User pruned with command. Pruned by: %s (%s)", + e.User().Username, e.User().ID))) + if err != nil { + slog.Error("Failed to prune member.", "err", err, "user_id", member.User.ID) + _, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContent("Failed to prune members: failed to remove member."). + Build()) + return err + } + } + + numKicked := len(kickedMembers) + + adminMessage := fmt.Sprintf("Pruned %d members.\n\nMembers:\n", numKicked) + + for _, member := range kickedMembers { + if member == nil { + continue + } + + adminMessage += fmt.Sprintf("-# %s (%s)\n", member.User.Username, member.User.ID) + } + + if numKicked > 0 && guildSettings.ModeratorChannel != 0 { + _, err = e.Client().Rest().CreateMessage(guildSettings.ModeratorChannel, discord.NewMessageCreateBuilder(). + SetContent(adminMessage). + SetEphemeral(true). + Build()) + if err != nil { + slog.Error("Failed to send prune message to moderator channel.", + "err", err, + "guild_id", *e.GuildID(), + "channel_id", guildSettings.ModeratorChannel, + "user_id", e.User().ID) + } + } + + _ = days + + _, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder(). + SetEphemeral(true). + SetContentf("Pruned %d users.", numKicked). + Build()) + return err +} + +func getPrunableMembers(e *handler.CommandEvent, days int, guildSettings *model.GuildSettings) (members []*discord.Member, err error) { + maxTimeDiff := time.Duration(days) * time.Hour * 24 + + for member := range utils.GetMembersIter(e.Client().Rest(), *e.GuildID()) { + if member.Error != nil { + return nil, member.Error + } + member := member.Value + + if !utils.HasRole(member, guildSettings.GatekeepPendingRole) { + continue + } + + if utils.HasRole(member, guildSettings.GatekeepApprovedRole) { + continue + } + + if time.Since(member.JoinedAt) < maxTimeDiff { + continue + } + + members = append(members, &member) + } + + return +} diff --git a/globals/globals.go b/globals/globals.go new file mode 100644 index 0000000..0892212 --- /dev/null +++ b/globals/globals.go @@ -0,0 +1,5 @@ +package globals + +import "github.com/disgoorg/snowflake/v2" + +var ExcludedFromModKickLog = make(map[snowflake.ID]struct{}) diff --git a/go.mod b/go.mod index e9858a8..91fa1c5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/myrkvi/heimdallr -go 1.22.0 +go 1.23.0 require ( github.com/cbroglie/mustache v1.4.0 diff --git a/listeners/audit_log.go b/listeners/audit_log.go index eaa25ea..54eb688 100644 --- a/listeners/audit_log.go +++ b/listeners/audit_log.go @@ -6,6 +6,8 @@ import ( "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/events" + + "github.com/myrkvi/heimdallr/globals" "github.com/myrkvi/heimdallr/model" "github.com/myrkvi/heimdallr/utils" ) @@ -26,6 +28,13 @@ func OnAuditLog(e *events.GuildAuditLogEntryCreate) { return } + if _, ok := globals.ExcludedFromModKickLog[targetUser.ID]; ok { + // User is excluded from mod kick log, likely because they were pruned. + // Remove from excluded list and don't log. + delete(globals.ExcludedFromModKickLog, targetUser.ID) + return + } + msg = fmt.Sprintf("User %s (`%d`) was kicked by %s.%s", targetUser.Username, targetUser.ID, user.Mention(), utils.Iif(entry.Reason != nil, fmt.Sprintf("\n\n>>> %s", *entry.Reason), "")) diff --git a/main.go b/main.go index c941be3..00e0547 100644 --- a/main.go +++ b/main.go @@ -115,6 +115,9 @@ func main() { r.Command("/create-role-button", commands.CreateRoleButtonHandler) r.Component("/role/assign/{roleID}", components.RoleAssignButtonHandler) + r.Command("/prune-pending-members", commands.PruneHandler) + r.Command("/prune-pending-members-dry-run", commands.PruneDryRunHandler) + commandCreates := []discord.ApplicationCommandCreate{ commands.PingCommand, commands.QuoteCommand, @@ -127,6 +130,8 @@ func main() { commands.ApproveSlashCommand, commands.ApproveUserCommand, commands.CreateRoleButtonCommand, + commands.PruneCommand, + commands.PruneDryRunCommand, } client, err := disgo.New(token, diff --git a/utils/users.go b/utils/users.go new file mode 100644 index 0000000..bcc3473 --- /dev/null +++ b/utils/users.go @@ -0,0 +1,50 @@ +package utils + +import ( + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/snowflake/v2" +) + +func HasRole(member discord.Member, roleID snowflake.ID) bool { + for _, role := range member.RoleIDs { + if role == roleID { + return true + } + } + + return false +} + +func HasRolesAll(member discord.Member, roleIDs ...snowflake.ID) bool { + hasRole := make(map[snowflake.ID]bool) + for _, role := range member.RoleIDs { + hasRole[role] = false + } + for _, role := range member.RoleIDs { + for _, roleID := range roleIDs { + if role == roleID { + hasRole[role] = true + } + } + } + + for _, hasRole := range hasRole { + if !hasRole { + return false + } + } + + return true +} + +func HasRolesAny(member discord.Member, roleIDs ...snowflake.ID) bool { + for _, role := range member.RoleIDs { + for _, roleID := range roleIDs { + if role == roleID { + return true + } + } + } + + return false +} diff --git a/utils/utils.go b/utils/utils.go index 75d455d..e29c822 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -3,11 +3,17 @@ package utils import ( "cmp" "errors" + "iter" + "log/slog" "math" "regexp" "strconv" "strings" "time" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/rest" + "github.com/disgoorg/snowflake/v2" ) func Ref[T any](v T) *T { @@ -120,3 +126,43 @@ func FormatFloatUpToPrec(num float64, prec int) string { return str } + +type IterResult[T any] struct { + Value T + Error error +} + +func GetMembersIter(r rest.Rest, guildID snowflake.ID) iter.Seq[IterResult[discord.Member]] { + const LIMIT int = 2 + memberOffset := snowflake.ID(0) + totalMembers := 0 + return func(yield func(IterResult[discord.Member]) bool) { + for { + members, err := r.GetMembers(guildID, LIMIT, memberOffset) + if err != nil { + yield(IterResult[discord.Member]{ + Error: err, + }) + } + + count := len(members) + totalMembers += count + + for _, member := range members { + if !yield(IterResult[discord.Member]{ + Value: member, + }) { + return + } + } + + if count < LIMIT { + slog.Debug("Finished getting members", "guild_id", guildID, "total_members", totalMembers) + return + } + slog.Debug("Retrieving more members", "guild_id", guildID, "total_members", totalMembers) + + memberOffset = members[len(members)-1].User.ID + } + } +} From 6499bb92f432393c57164be13a8d34193d076d23 Mon Sep 17 00:00:00 2001 From: Vegard Berg Date: Sat, 16 Nov 2024 02:19:35 +0100 Subject: [PATCH 2/4] chore: Update go version for CI tasks --- .github/workflows/lint.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e066c8e..fa7ff32 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" cache: false - name: GolangCi-Lint uses: golangci/golangci-lint-action@v6.1.1 @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" - name: Test run: go test -v ./... @@ -45,7 +45,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" - name: Build run: go build -v ./... From 266cf8c0ea56661eccdbf78129dfed36e54a39b9 Mon Sep 17 00:00:00 2001 From: Vegard Berg Date: Sat, 16 Nov 2024 02:23:28 +0100 Subject: [PATCH 3/4] Don't allow pruning users < 3 days --- commands/prune.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/prune.go b/commands/prune.go index c6ec17c..92e4c4c 100644 --- a/commands/prune.go +++ b/commands/prune.go @@ -135,7 +135,7 @@ var PruneCommand = discord.SlashCommandCreate{ }, Required: true, - MinValue: utils.Ref(0), + MinValue: utils.Ref(3), MaxValue: utils.Ref(90), }, }, From d56ee2123ecaaf44250b82027c5748976e749eed Mon Sep 17 00:00:00 2001 From: Vegard Berg Date: Sat, 16 Nov 2024 02:27:27 +0100 Subject: [PATCH 4/4] chore: Update golangci-lint --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fa7ff32..e60628e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: - name: GolangCi-Lint uses: golangci/golangci-lint-action@v6.1.1 with: - version: v1.59.1 + version: v1.62.0 args: --timeout=5m test: name: Test