Skip to content

Commit fefbe21

Browse files
committed
feat: Prune and Prune dry run command
1 parent 8d659da commit fefbe21

File tree

7 files changed

+374
-1
lines changed

7 files changed

+374
-1
lines changed

commands/prune.go

+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"time"
7+
8+
"github.com/disgoorg/disgo/discord"
9+
"github.com/disgoorg/disgo/handler"
10+
"github.com/disgoorg/disgo/rest"
11+
"github.com/disgoorg/json"
12+
13+
"github.com/myrkvi/heimdallr/globals"
14+
"github.com/myrkvi/heimdallr/model"
15+
"github.com/myrkvi/heimdallr/utils"
16+
)
17+
18+
var PruneDryRunCommand = discord.SlashCommandCreate{
19+
Name: "prune-pending-members-dry-run",
20+
NameLocalizations: map[discord.Locale]string{
21+
discord.LocaleNorwegian: "fjern-ventende-medlemmer-dry-run",
22+
},
23+
Description: "Prune members.",
24+
DescriptionLocalizations: map[discord.Locale]string{
25+
discord.LocaleNorwegian: "Fjern medlemmer.",
26+
},
27+
28+
Contexts: []discord.InteractionContextType{
29+
discord.InteractionContextTypeGuild,
30+
},
31+
IntegrationTypes: []discord.ApplicationIntegrationType{
32+
discord.ApplicationIntegrationTypeGuildInstall,
33+
},
34+
35+
DefaultMemberPermissions: json.NewNullablePtr(discord.PermissionManageGuild),
36+
37+
Options: []discord.ApplicationCommandOption{
38+
discord.ApplicationCommandOptionInt{
39+
Name: "days",
40+
NameLocalizations: map[discord.Locale]string{
41+
discord.LocaleNorwegian: "dager",
42+
},
43+
Description: "The number of days to prune members for.",
44+
DescriptionLocalizations: map[discord.Locale]string{
45+
discord.LocaleNorwegian: "Antall dager å fjerne medlemmer for.",
46+
},
47+
Required: true,
48+
49+
MinValue: utils.Ref(0),
50+
MaxValue: utils.Ref(90),
51+
},
52+
},
53+
}
54+
55+
func PruneDryRunHandler(e *handler.CommandEvent) error {
56+
if e.GuildID() == nil {
57+
return ErrEventNoGuildID
58+
}
59+
days := e.SlashCommandInteractionData().Int("days")
60+
61+
guildSettings, err := model.GetGuildSettings(*e.GuildID())
62+
if err != nil {
63+
_ = e.CreateMessage(discord.NewMessageCreateBuilder().
64+
SetEphemeral(true).
65+
SetContent("Failed to prune members: could not get guild settings.").
66+
Build())
67+
return err
68+
}
69+
70+
if guildSettings.GatekeepPendingRole == 0 {
71+
return e.CreateMessage(discord.NewMessageCreateBuilder().
72+
SetEphemeral(true).
73+
SetContent("Failed to prune members: no pending role set. This command will only prune pending members.").
74+
Build())
75+
}
76+
77+
_ = e.DeferCreateMessage(true)
78+
79+
prunableMembers, err := getPrunableMembers(e, days, guildSettings)
80+
if err != nil {
81+
_, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder().
82+
SetEphemeral(true).
83+
SetContent("Failed to prune members: could not get member list.").
84+
Build())
85+
return err
86+
}
87+
88+
numKicked := len(prunableMembers)
89+
90+
adminMessage := fmt.Sprintf("Dry run: pruned %d members.\n\nMembers:\n", numKicked)
91+
92+
for _, member := range prunableMembers {
93+
if member == nil {
94+
continue
95+
}
96+
97+
adminMessage += fmt.Sprintf("-# %s (%s)\n", member.User.Username, member.User.ID)
98+
}
99+
100+
_, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder().
101+
SetEphemeral(true).
102+
SetContent(adminMessage).
103+
Build())
104+
return err
105+
}
106+
107+
var PruneCommand = discord.SlashCommandCreate{
108+
Name: "prune-pending-members",
109+
NameLocalizations: map[discord.Locale]string{
110+
discord.LocaleNorwegian: "fjern-ventende-medlemmer",
111+
},
112+
Description: "Prune members.",
113+
DescriptionLocalizations: map[discord.Locale]string{
114+
discord.LocaleNorwegian: "Fjern medlemmer.",
115+
},
116+
117+
Contexts: []discord.InteractionContextType{
118+
discord.InteractionContextTypeGuild,
119+
},
120+
IntegrationTypes: []discord.ApplicationIntegrationType{
121+
discord.ApplicationIntegrationTypeGuildInstall,
122+
},
123+
124+
DefaultMemberPermissions: json.NewNullablePtr(discord.PermissionManageGuild),
125+
126+
Options: []discord.ApplicationCommandOption{
127+
discord.ApplicationCommandOptionInt{
128+
Name: "days",
129+
NameLocalizations: map[discord.Locale]string{
130+
discord.LocaleNorwegian: "dager",
131+
},
132+
Description: "The number of days to prune members for.",
133+
DescriptionLocalizations: map[discord.Locale]string{
134+
discord.LocaleNorwegian: "Antall dager å fjerne medlemmer for.",
135+
},
136+
Required: true,
137+
138+
MinValue: utils.Ref(0),
139+
MaxValue: utils.Ref(90),
140+
},
141+
},
142+
}
143+
144+
func PruneHandler(e *handler.CommandEvent) error {
145+
if e.GuildID() == nil {
146+
return ErrEventNoGuildID
147+
}
148+
days := e.SlashCommandInteractionData().Int("days")
149+
150+
guildSettings, err := model.GetGuildSettings(*e.GuildID())
151+
if err != nil {
152+
_ = e.CreateMessage(discord.NewMessageCreateBuilder().
153+
SetEphemeral(true).
154+
SetContent("Failed to prune members: could not get guild settings.").
155+
Build())
156+
return err
157+
}
158+
159+
if guildSettings.GatekeepPendingRole == 0 {
160+
return e.CreateMessage(discord.NewMessageCreateBuilder().
161+
SetEphemeral(true).
162+
SetContent("Failed to prune members: no pending role set. This command will only prune pending members.").
163+
Build())
164+
}
165+
166+
_ = e.DeferCreateMessage(true)
167+
168+
var kickedMembers []*discord.Member
169+
170+
prunableMembers, err := getPrunableMembers(e, days, guildSettings)
171+
if err != nil {
172+
_, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder().
173+
SetEphemeral(true).
174+
SetContent("Failed to prune members: could not get member list.").
175+
Build())
176+
return err
177+
}
178+
179+
for _, member := range prunableMembers {
180+
181+
globals.ExcludedFromModKickLog[member.User.ID] = struct{}{}
182+
kickedMembers = append(kickedMembers, member)
183+
184+
err = e.Client().Rest().RemoveMember(*e.GuildID(), member.User.ID,
185+
rest.WithReason(
186+
fmt.Sprintf("User pruned with command. Pruned by: %s (%s)",
187+
e.User().Username, e.User().ID)))
188+
if err != nil {
189+
slog.Error("Failed to prune member.", "err", err, "user_id", member.User.ID)
190+
_, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder().
191+
SetEphemeral(true).
192+
SetContent("Failed to prune members: failed to remove member.").
193+
Build())
194+
return err
195+
}
196+
}
197+
198+
numKicked := len(kickedMembers)
199+
200+
adminMessage := fmt.Sprintf("Pruned %d members.\n\nMembers:\n", numKicked)
201+
202+
for _, member := range kickedMembers {
203+
if member == nil {
204+
continue
205+
}
206+
207+
adminMessage += fmt.Sprintf("-# %s (%s)\n", member.User.Username, member.User.ID)
208+
}
209+
210+
if numKicked > 0 && guildSettings.ModeratorChannel != 0 {
211+
_, err = e.Client().Rest().CreateMessage(guildSettings.ModeratorChannel, discord.NewMessageCreateBuilder().
212+
SetContent(adminMessage).
213+
SetEphemeral(true).
214+
Build())
215+
if err != nil {
216+
slog.Error("Failed to send prune message to moderator channel.",
217+
"err", err,
218+
"guild_id", *e.GuildID(),
219+
"channel_id", guildSettings.ModeratorChannel,
220+
"user_id", e.User().ID)
221+
}
222+
}
223+
224+
_ = days
225+
226+
_, err = e.CreateFollowupMessage(discord.NewMessageCreateBuilder().
227+
SetEphemeral(true).
228+
SetContentf("Pruned %d users.", numKicked).
229+
Build())
230+
return err
231+
}
232+
233+
func getPrunableMembers(e *handler.CommandEvent, days int, guildSettings *model.GuildSettings) (members []*discord.Member, err error) {
234+
maxTimeDiff := time.Duration(days) * time.Hour * 24
235+
236+
for member := range utils.GetMembersIter(e.Client().Rest(), *e.GuildID()) {
237+
if member.Error != nil {
238+
return nil, member.Error
239+
}
240+
member := member.Value
241+
242+
if !utils.HasRole(member, guildSettings.GatekeepPendingRole) {
243+
continue
244+
}
245+
246+
if utils.HasRole(member, guildSettings.GatekeepApprovedRole) {
247+
continue
248+
}
249+
250+
if time.Since(member.JoinedAt) < maxTimeDiff {
251+
continue
252+
}
253+
254+
members = append(members, &member)
255+
}
256+
257+
return
258+
}

globals/globals.go

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package globals
2+
3+
import "github.com/disgoorg/snowflake/v2"
4+
5+
var ExcludedFromModKickLog = make(map[snowflake.ID]struct{})

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/myrkvi/heimdallr
22

3-
go 1.22.0
3+
go 1.23.0
44

55
require (
66
github.com/cbroglie/mustache v1.4.0

listeners/audit_log.go

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66

77
"github.com/disgoorg/disgo/discord"
88
"github.com/disgoorg/disgo/events"
9+
10+
"github.com/myrkvi/heimdallr/globals"
911
"github.com/myrkvi/heimdallr/model"
1012
"github.com/myrkvi/heimdallr/utils"
1113
)
@@ -26,6 +28,13 @@ func OnAuditLog(e *events.GuildAuditLogEntryCreate) {
2628
return
2729
}
2830

31+
if _, ok := globals.ExcludedFromModKickLog[targetUser.ID]; ok {
32+
// User is excluded from mod kick log, likely because they were pruned.
33+
// Remove from excluded list and don't log.
34+
delete(globals.ExcludedFromModKickLog, targetUser.ID)
35+
return
36+
}
37+
2938
msg = fmt.Sprintf("User %s (`%d`) was kicked by %s.%s", targetUser.Username, targetUser.ID,
3039
user.Mention(),
3140
utils.Iif(entry.Reason != nil, fmt.Sprintf("\n\n>>> %s", *entry.Reason), ""))

main.go

+5
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ func main() {
115115
r.Command("/create-role-button", commands.CreateRoleButtonHandler)
116116
r.Component("/role/assign/{roleID}", components.RoleAssignButtonHandler)
117117

118+
r.Command("/prune-pending-members", commands.PruneHandler)
119+
r.Command("/prune-pending-members-dry-run", commands.PruneDryRunHandler)
120+
118121
commandCreates := []discord.ApplicationCommandCreate{
119122
commands.PingCommand,
120123
commands.QuoteCommand,
@@ -127,6 +130,8 @@ func main() {
127130
commands.ApproveSlashCommand,
128131
commands.ApproveUserCommand,
129132
commands.CreateRoleButtonCommand,
133+
commands.PruneCommand,
134+
commands.PruneDryRunCommand,
130135
}
131136

132137
client, err := disgo.New(token,

utils/users.go

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package utils
2+
3+
import (
4+
"github.com/disgoorg/disgo/discord"
5+
"github.com/disgoorg/snowflake/v2"
6+
)
7+
8+
func HasRole(member discord.Member, roleID snowflake.ID) bool {
9+
for _, role := range member.RoleIDs {
10+
if role == roleID {
11+
return true
12+
}
13+
}
14+
15+
return false
16+
}
17+
18+
func HasRolesAll(member discord.Member, roleIDs ...snowflake.ID) bool {
19+
hasRole := make(map[snowflake.ID]bool)
20+
for _, role := range member.RoleIDs {
21+
hasRole[role] = false
22+
}
23+
for _, role := range member.RoleIDs {
24+
for _, roleID := range roleIDs {
25+
if role == roleID {
26+
hasRole[role] = true
27+
}
28+
}
29+
}
30+
31+
for _, hasRole := range hasRole {
32+
if !hasRole {
33+
return false
34+
}
35+
}
36+
37+
return true
38+
}
39+
40+
func HasRolesAny(member discord.Member, roleIDs ...snowflake.ID) bool {
41+
for _, role := range member.RoleIDs {
42+
for _, roleID := range roleIDs {
43+
if role == roleID {
44+
return true
45+
}
46+
}
47+
}
48+
49+
return false
50+
}

0 commit comments

Comments
 (0)