|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+import weakref
|
|
|
2
|
+
|
|
|
3
|
+from datetime import datetime, timedelta
|
|
|
4
|
+from discord import Guild, Member
|
|
|
5
|
+from discord.ext import commands
|
|
|
6
|
+
|
|
|
7
|
+from config import CONFIG
|
|
|
8
|
+from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
|
|
|
9
|
+from rocketbot.collections import AgeBoundList
|
|
|
10
|
+from rocketbot.storage import Storage
|
|
|
11
|
+from rocketbot.utils import timedelta_from_str
|
|
|
12
|
+
|
|
|
13
|
+class JoinAgeQueryContext:
|
|
|
14
|
+ """
|
|
|
15
|
+ Data about a join age query
|
|
|
16
|
+ """
|
|
|
17
|
+ def __init__(self, join_members: list, timespan: str):
|
|
|
18
|
+ self.join_members = list(join_members)
|
|
|
19
|
+ self.timespan = timespan
|
|
|
20
|
+ self.kicked_members = set()
|
|
|
21
|
+ self.banned_members = set()
|
|
|
22
|
+ self.results_message_ref = None
|
|
|
23
|
+
|
|
|
24
|
+class JoinAgeCog(BaseCog, name='Join Age'):
|
|
|
25
|
+ """
|
|
|
26
|
+ Cog for finding users by when they joined.
|
|
|
27
|
+ """
|
|
|
28
|
+ SETTING_ENABLED = CogSetting('enabled', bool,
|
|
|
29
|
+ brief='join age',
|
|
|
30
|
+ description='Whether this cog is enabled for a guild.')
|
|
|
31
|
+ SETTING_JOIN_TIME = CogSetting('jointime', float,
|
|
|
32
|
+ brief='maximum length of time to track new joins',
|
|
|
33
|
+ description='The number of seconds of join history to maintain.',
|
|
|
34
|
+ usage='<seconds:float>',
|
|
|
35
|
+ min_value=1.0)
|
|
|
36
|
+
|
|
|
37
|
+ STATE_KEY_RECENT_JOINS = "JoinAgeCog.recent_joins"
|
|
|
38
|
+
|
|
|
39
|
+ def __init__(self, bot):
|
|
|
40
|
+ super().__init__(bot)
|
|
|
41
|
+ self.add_setting(JoinAgeCog.SETTING_ENABLED)
|
|
|
42
|
+ self.add_setting(JoinAgeCog.SETTING_JOIN_TIME)
|
|
|
43
|
+
|
|
|
44
|
+ @commands.group(
|
|
|
45
|
+ brief='Tracks recently joined users with options to mass kick or ban',
|
|
|
46
|
+ )
|
|
|
47
|
+ @commands.has_permissions(ban_members=True)
|
|
|
48
|
+ @commands.guild_only()
|
|
|
49
|
+ async def joinage(self, context: commands.Context):
|
|
|
50
|
+ 'Join age tracking'
|
|
|
51
|
+ if context.invoked_subcommand is None:
|
|
|
52
|
+ await context.send_help()
|
|
|
53
|
+
|
|
|
54
|
+ @joinage.command(
|
|
|
55
|
+ brief='Queries for users who joined in the past span of time',
|
|
|
56
|
+ description='Searches for users who joined the server recently. ' + \
|
|
|
57
|
+ 'Can use time spans like 30s, 5m, 1h, 7d, etc.',
|
|
|
58
|
+ usage='<time_period>'
|
|
|
59
|
+ )
|
|
|
60
|
+ async def search(self, context: commands.Context, timespan: str):
|
|
|
61
|
+ 'Command handler'
|
|
|
62
|
+ guild: Guild = context.guild
|
|
|
63
|
+ recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
|
|
|
64
|
+ if recent_joins is None:
|
|
|
65
|
+ max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
|
|
|
66
|
+ recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)
|
|
|
67
|
+ Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
|
|
|
68
|
+ results: list = []
|
|
|
69
|
+ ts: timedelta = timedelta_from_str(timespan)
|
|
|
70
|
+ cutoff: datetime = datetime.utcnow() - ts
|
|
|
71
|
+ for member in recent_joins:
|
|
|
72
|
+ if member.joined_at > cutoff:
|
|
|
73
|
+ results.append(member)
|
|
|
74
|
+ ctx = JoinAgeQueryContext(results, timespan)
|
|
|
75
|
+ msg = BotMessage(guild,
|
|
|
76
|
+ text='',
|
|
|
77
|
+ type=BotMessage.TYPE_INFO,
|
|
|
78
|
+ context=ctx)
|
|
|
79
|
+ ctx.results_message_ref = weakref.ref(msg)
|
|
|
80
|
+ await self.__update_results_message(ctx)
|
|
|
81
|
+ await self.post_message(msg)
|
|
|
82
|
+
|
|
|
83
|
+ async def on_mod_react(self,
|
|
|
84
|
+ bot_message: BotMessage,
|
|
|
85
|
+ reaction: BotMessageReaction,
|
|
|
86
|
+ reacted_by: Member) -> None:
|
|
|
87
|
+ guild: Guild = bot_message.guild
|
|
|
88
|
+ ctx: JoinAgeQueryContext = bot_message.context
|
|
|
89
|
+ if reaction.emoji == CONFIG['kick_emoji']:
|
|
|
90
|
+ to_kick = set(ctx.join_members) - ctx.kicked_members
|
|
|
91
|
+ for member in to_kick:
|
|
|
92
|
+ await member.kick(
|
|
|
93
|
+ reason=f'Rocketbot: Mass kick based on join age, by {reacted_by.name}.')
|
|
|
94
|
+ ctx.kicked_members |= to_kick
|
|
|
95
|
+ await self.__update_results_message(ctx)
|
|
|
96
|
+ self.log(guild, f'Users kicked by {reacted_by.name}.')
|
|
|
97
|
+ elif reaction.emoji == CONFIG['ban_emoji']:
|
|
|
98
|
+ to_ban = set(ctx.join_members) - ctx.banned_members
|
|
|
99
|
+ for member in to_ban:
|
|
|
100
|
+ await member.ban(
|
|
|
101
|
+ reason=f'Rocketbot: Mass ban based on join age, by {reacted_by.name}.',
|
|
|
102
|
+ delete_message_days=0)
|
|
|
103
|
+ ctx.banned_members |= to_ban
|
|
|
104
|
+ await self.__update_results_message(ctx)
|
|
|
105
|
+ self.log(guild, f'Users banned by {reacted_by.name}')
|
|
|
106
|
+
|
|
|
107
|
+ @commands.Cog.listener()
|
|
|
108
|
+ async def on_member_join(self, member: Member) -> None:
|
|
|
109
|
+ 'Event handler'
|
|
|
110
|
+ guild: Guild = member.guild
|
|
|
111
|
+ if not self.get_guild_setting(guild, self.SETTING_ENABLED):
|
|
|
112
|
+ return
|
|
|
113
|
+ recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
|
|
|
114
|
+ if recent_joins is None:
|
|
|
115
|
+ max_age: timedelta = timedelta(seconds=self.get_guild_setting(guild, self.SETTING_JOIN_TIME))
|
|
|
116
|
+ recent_joins = AgeBoundList(max_age, lambda i, member : member.joined_at)
|
|
|
117
|
+ Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
|
|
|
118
|
+ recent_joins.append(member)
|
|
|
119
|
+
|
|
|
120
|
+ async def __update_results_message(self, context: JoinAgeQueryContext) -> None:
|
|
|
121
|
+ if context.results_message_ref is None:
|
|
|
122
|
+ return
|
|
|
123
|
+ bot_message = context.results_message_ref()
|
|
|
124
|
+ if bot_message is None:
|
|
|
125
|
+ return
|
|
|
126
|
+ text = f'The following members joined in the last {context.timespan}\n\n'
|
|
|
127
|
+ if len(context.join_members) > 0:
|
|
|
128
|
+ for member in context.join_members:
|
|
|
129
|
+ text += '\n• '
|
|
|
130
|
+ if member in context.banned_members:
|
|
|
131
|
+ text += f'~~{member.mention} ({member.id})~~ - banned'
|
|
|
132
|
+ elif member in context.kicked_members:
|
|
|
133
|
+ text += f'~~{member.mention} ({member.id})~~ - kicked'
|
|
|
134
|
+ else:
|
|
|
135
|
+ text += f'{member.mention} ({member.id})'
|
|
|
136
|
+ else:
|
|
|
137
|
+ text += 'No members found. If the bot was recently restarted ' + \
|
|
|
138
|
+ 'or JoinAgeCog just enabled, only new joins will be tracked.'
|
|
|
139
|
+ await bot_message.set_text(text)
|
|
|
140
|
+ if len(context.join_members) > 0:
|
|
|
141
|
+ member_count = len(context.join_members)
|
|
|
142
|
+ kick_count = len(context.kicked_members)
|
|
|
143
|
+ ban_count = len(context.banned_members)
|
|
|
144
|
+ await bot_message.set_reactions(BotMessageReaction.standard_set(
|
|
|
145
|
+ did_kick=kick_count >= member_count,
|
|
|
146
|
+ did_ban=ban_count >= member_count,
|
|
|
147
|
+ user_count=member_count))
|