Bladeren bron

- Autokick optionally bans after N rejoins

- Bot messages with lists of users are capped at 20 names to avoid 4kb request limit
- Redundant warnings should ping mods less
master
Rocketsoup 2 jaren geleden
bovenliggende
commit
db3a5bcdd9

+ 5
- 0
config.py.sample Bestand weergeven

12
 	'info_emoji': 'ℹ️',
12
 	'info_emoji': 'ℹ️',
13
 	'ignore_emoji': '👍',
13
 	'ignore_emoji': '👍',
14
 	'config_path': 'config/',
14
 	'config_path': 'config/',
15
+	'max_members_per_message': 20,
16
+	'squelch_warning_seconds': 300,
15
 	'cog_defaults': {
17
 	'cog_defaults': {
18
+		'AutoKickCog': {
19
+			'bancount': 0
20
+		},
16
 		'CrossPostCog': {
21
 		'CrossPostCog': {
17
 			'enabled': False,
22
 			'enabled': False,
18
 			'warncount': 3,
23
 			'warncount': 3,

+ 74
- 9
rocketbot/cogs/autokickcog.py Bestand weergeven

1
-import weakref
1
+from datetime import datetime, timedelta
2
 
2
 
3
 from discord import Guild, Member
3
 from discord import Guild, Member
4
 from discord.ext import commands
4
 from discord.ext import commands
5
 
5
 
6
 from config import CONFIG
6
 from config import CONFIG
7
-from rocketbot.cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
7
+from rocketbot.cogs.basecog import BaseCog, BotMessage, CogSetting
8
+from rocketbot.collections import AgeBoundDict
9
+from rocketbot.storage import Storage
10
+
11
+class AutoKickContext:
12
+	"""
13
+	Data about a join raid.
14
+	"""
15
+	def __init__(self, member: Member, first_kick: datetime):
16
+		self.member = member
17
+		self.first_kick = first_kick
18
+		self.last_kick = first_kick
19
+		self.kick_count = 1
20
+
21
+	def record_kick(self, time: datetime):
22
+		self.last_kick = time
23
+		self.kick_count += 1
8
 
24
 
9
 class AutoKickCog(BaseCog, name='Auto Kick'):
25
 class AutoKickCog(BaseCog, name='Auto Kick'):
10
 	"""
26
 	"""
13
 	SETTING_ENABLED = CogSetting('enabled', bool,
29
 	SETTING_ENABLED = CogSetting('enabled', bool,
14
 		brief='autokick',
30
 		brief='autokick',
15
 		description='Whether this cog is enabled for a guild.')
31
 		description='Whether this cog is enabled for a guild.')
32
+	SETTING_BAN_COUNT = CogSetting('bancount', int,
33
+			brief='number of repeat kicks before a ban',
34
+			description='The number of times a user can join and be kicked ' + \
35
+					'before the next rejoin will result in a ban. A value of 0 ' + \
36
+					'disables this feature (only kick, never ban).',
37
+			usage='<count:int>',
38
+			min_value=0)
39
+
40
+	STATE_KEY_RECENT_KICKS = "AutoKickCog.recent_joins"
16
 
41
 
17
 	def __init__(self, bot):
42
 	def __init__(self, bot):
18
 		super().__init__(bot)
43
 		super().__init__(bot)
19
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
44
 		self.add_setting(AutoKickCog.SETTING_ENABLED)
45
+		self.add_setting(AutoKickCog.SETTING_BAN_COUNT)
20
 
46
 
21
 	@commands.group(
47
 	@commands.group(
22
 		brief='Automatically kicks all new users as soon as they join',
48
 		brief='Automatically kicks all new users as soon as they join',
34
 		guild: Guild = member.guild
60
 		guild: Guild = member.guild
35
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
61
 		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
36
 			return
62
 			return
37
-		await member.kick(reason=f'Rocketbot: Autokick enabled.')
38
-		msg = BotMessage(guild,
39
-						text=f'Autokicked {member.mention} ({member.id}). To disable this feature: `{CONFIG["command_prefix"]}autokick disable`.',
40
-						type=BotMessage.TYPE_INFO,
41
-						context=None)
42
-		await self.post_message(msg)
43
-		self.log(guild, f'Autokicked {member.name}')
63
+		recent_kicks: AgeBoundDict = Storage.get_state_value(guild, AutoKickCog.STATE_KEY_RECENT_KICKS)
64
+		if recent_kicks is None:
65
+			recent_kicks = AgeBoundDict(timedelta(seconds=3600), lambda i, context : context.last_kick)
66
+			Storage.set_state_value(guild, self.STATE_KEY_RECENT_KICKS, recent_kicks)
67
+		context: AutoKickContext = recent_kicks.get(member.id)
68
+		if context is None:
69
+			context = AutoKickContext(member, datetime.now())
70
+			recent_kicks[member.id] = context
71
+		else:
72
+			context.record_kick(datetime.now())
73
+		max_kick_count: int = self.get_guild_setting(guild, self.SETTING_BAN_COUNT)
74
+		disable_help = f'To disable this feature: `{CONFIG["command_prefix"]}autokick disable`.'
75
+		ban_help = f'To configure ban threshold: `{CONFIG["command_prefix"]}autokick ' + \
76
+			'setbancount #` (0 to disable).'
77
+		if max_kick_count > 0 and context.kick_count > max_kick_count:
78
+			await member.ban(reason=f'Rocketbot: Ban after {context.kick_count} joins',
79
+				delete_message_days=0)
80
+			msg = BotMessage(guild,
81
+				text=f'Banned {member.mention} ({member.id}) after {context.kick_count} joins. ' + \
82
+					disable_help + ' ' + ban_help,
83
+				type=BotMessage.TYPE_INFO,
84
+				context=None)
85
+			await self.post_message(msg)
86
+			self.log(guild, f'Banned {member.name} after {context.kick_count} joins')
87
+		else:
88
+			await member.kick(reason='Rocketbot: Autokick enabled.')
89
+			msg = BotMessage(guild,
90
+							text=f'Autokicked {member.mention} ({member.id}) ' + \
91
+								f'({AutoKickCog.ordinal(context.kick_count)} time). ' + \
92
+								disable_help + ' ' + ban_help,
93
+							type=BotMessage.TYPE_INFO,
94
+							context=None)
95
+			await self.post_message(msg)
96
+			self.log(guild, f'Autokicked {member.name} ' + \
97
+				f'({AutoKickCog.ordinal(context.kick_count)} time)')
98
+
99
+	@staticmethod
100
+	def ordinal(val: int):
101
+		'Formats an integer with an ordinal suffix (English only)'
102
+		if val % 10 == 1:
103
+			return f'{val}st'
104
+		if val % 10 == 2:
105
+			return f'{val}nd'
106
+		if val % 10 == 3:
107
+			return f'{val}rd'
108
+		return f'{val}th'

+ 54
- 0
rocketbot/cogs/basecog.py Bestand weergeven

13
 from rocketbot.storage import Storage
13
 from rocketbot.storage import Storage
14
 from rocketbot.utils import bot_log
14
 from rocketbot.utils import bot_log
15
 
15
 
16
+class WarningContext:
17
+	def __init__(self, member: Member, warn_time: datetime):
18
+		self.member = member
19
+		self.last_warned = warn_time
20
+
16
 class BaseCog(commands.Cog):
21
 class BaseCog(commands.Cog):
22
+	STATE_KEY_RECENT_WARNINGS = "BaseCog.recent_warnings"
23
+
17
 	"""
24
 	"""
18
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
25
 	Superclass for all Rocketbot cogs. Provides lots of conveniences for
19
 	common tasks.
26
 	common tasks.
98
 		Subclass override point for being notified when a CogSetting is edited.
105
 		Subclass override point for being notified when a CogSetting is edited.
99
 		"""
106
 		"""
100
 
107
 
108
+	# Warning squelch
109
+
110
+	def was_warned_recently(self, member: Member) -> bool:
111
+		"""
112
+		Tests if a given member was included in a mod warning message recently.
113
+		Used to suppress redundant messages. Should be checked before pinging
114
+		mods for relatively minor warnings about single users, but warnings
115
+		about larger threats involving several members (e.g. join raids) should
116
+		issue warnings regardless. Call record_warning or record_warnings after
117
+		triggering a mod warning.
118
+		"""
119
+		recent_warns: AgeBoundDict = Storage.get_state_value(member.guild,
120
+			BaseCog.STATE_KEY_RECENT_WARNINGS)
121
+		if recent_warns is None:
122
+			return False
123
+		context: WarningContext = recent_warns.get(member.id)
124
+		if context is None:
125
+			return False
126
+		squelch_warning_seconds: int = CONFIG['squelch_warning_seconds']
127
+		return datetime.now - context.last_warned < timedelta(seconds=squelch_warning_seconds)
128
+
129
+	def record_warning(self, member: Member):
130
+		"""
131
+		Records that mods have been warned about a member and do not need to be
132
+		warned about them again for a short while.
133
+		"""
134
+		recent_warns: AgeBoundDict = Storage.get_state_value(member.guild,
135
+			BaseCog.STATE_KEY_RECENT_WARNINGS)
136
+		if recent_warns is None:
137
+			recent_warns = AgeBoundDict(timedelta(seconds=CONFIG['squelch_warning_seconds']),
138
+				lambda i, context : context.last_warned)
139
+			Storage.set_state_value(member.guild, BaseCog.STATE_KEY_RECENT_WARNINGS, recent_warns)
140
+		context: WarningContext = recent_warns.get(member.id)
141
+		if context is None:
142
+			context = WarningContext(member, datetime.now())
143
+			recent_warns[member.id] = context
144
+		else:
145
+			context.last_warned = datetime.now()
146
+
147
+	def record_warnings(self, members: list):
148
+		"""
149
+		Records that mods have been warned about some members and do not need to
150
+		be warned about them again for a short while.
151
+		"""
152
+		for member in members:
153
+			self.record_warning(member)
154
+
101
 	# Bot message handling
155
 	# Bot message handling
102
 
156
 
103
 	@classmethod
157
 	@classmethod

+ 4
- 2
rocketbot/cogs/crosspostcog.py Bestand weergeven

169
 		deleted_count = len(context.spam_messages)
169
 		deleted_count = len(context.spam_messages)
170
 		message = context.bot_message
170
 		message = context.bot_message
171
 		if message is None:
171
 		if message is None:
172
-			message = BotMessage(context.member.guild, '',
173
-				BotMessage.TYPE_MOD_WARNING, context)
172
+			message_type: int = BotMessage.TYPE_INFO if self.was_warned_recently(context.member) \
173
+				else BotMessage.TYPE_MOD_WARNING
174
+			message = BotMessage(context.member.guild, '', message_type, context)
174
 			message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
175
 			message.quote = discordutils.remove_markdown(first_spam_message.clean_content)
176
+			self.record_warning(context.member)
175
 		if context.is_autobanned:
177
 		if context.is_autobanned:
176
 			text = f'User {context.member.mention} auto banned for ' + \
178
 			text = f'User {context.member.mention} auto banned for ' + \
177
 				f'posting the same message in {channel_count} channels. ' + \
179
 				f'posting the same message in {channel_count} channels. ' + \

+ 4
- 1
rocketbot/cogs/joinagecog.py Bestand weergeven

125
 			return
125
 			return
126
 		text = f'The following members joined in the last {context.timespan}\n\n'
126
 		text = f'The following members joined in the last {context.timespan}\n\n'
127
 		if len(context.join_members) > 0:
127
 		if len(context.join_members) > 0:
128
-			for member in context.join_members:
128
+			max_members = CONFIG['max_members_per_message']
129
+			for member in context.join_members[:max_members]:
129
 				text += '\n• '
130
 				text += '\n• '
130
 				if member in context.banned_members:
131
 				if member in context.banned_members:
131
 					text += f'~~{member.mention} ({member.id})~~ - banned'
132
 					text += f'~~{member.mention} ({member.id})~~ - banned'
133
 					text += f'~~{member.mention} ({member.id})~~ - kicked'
134
 					text += f'~~{member.mention} ({member.id})~~ - kicked'
134
 				else:
135
 				else:
135
 					text += f'{member.mention} ({member.id})'
136
 					text += f'{member.mention} ({member.id})'
137
+			if len(context.join_members) > max_members:
138
+				text += f'\n• {len(context.join_members) - max_members} more'
136
 		else:
139
 		else:
137
 			text += 'No members found. If the bot was recently restarted ' + \
140
 			text += 'No members found. If the bot was recently restarted ' + \
138
 				'or JoinAgeCog just enabled, only new joins will be tracked.'
141
 				'or JoinAgeCog just enabled, only new joins will be tracked.'

+ 8
- 3
rocketbot/cogs/joinraidcog.py Bestand weergeven

113
 				return
113
 				return
114
 			# Add join to existing raid
114
 			# Add join to existing raid
115
 			last_raid.join_members.append(member)
115
 			last_raid.join_members.append(member)
116
+			self.record_warning(member)
116
 			if len(last_raid.banned_members) > 0:
117
 			if len(last_raid.banned_members) > 0:
117
 				self.log(guild, f'Banning as part of last join raid: {member.name}')
118
 				self.log(guild, f'Banning as part of last join raid: {member.name}')
118
 				await member.ban(
119
 				await member.ban(
119
-					reason=f'Rocketbot: Part of join raid.',
120
+					reason='Rocketbot: Part of join raid.',
120
 					delete_message_days=0)
121
 					delete_message_days=0)
121
 				last_raid.banned_members.add(member)
122
 				last_raid.banned_members.add(member)
122
 			elif len(last_raid.kicked_members) > 0:
123
 			elif len(last_raid.kicked_members) > 0:
123
 				self.log(guild, f'Kicking as part of last join raid: {member.name}')
124
 				self.log(guild, f'Kicking as part of last join raid: {member.name}')
124
 				await member.kick(
125
 				await member.kick(
125
-					reason=f'Rocketbot: Part of join raid.')
126
+					reason='Rocketbot: Part of join raid.')
126
 				last_raid.kicked_members.add(member)
127
 				last_raid.kicked_members.add(member)
127
 			await self.__update_warning_message(last_raid)
128
 			await self.__update_warning_message(last_raid)
128
 		else:
129
 		else:
137
 						text='',
138
 						text='',
138
 						type=BotMessage.TYPE_MOD_WARNING,
139
 						type=BotMessage.TYPE_MOD_WARNING,
139
 						context=last_raid)
140
 						context=last_raid)
141
+				self.record_warnings(recent_joins)
140
 				last_raid.warning_message_ref = weakref.ref(msg)
142
 				last_raid.warning_message_ref = weakref.ref(msg)
141
 				await self.__update_warning_message(last_raid)
143
 				await self.__update_warning_message(last_raid)
142
 				await self.post_message(msg)
144
 				await self.post_message(msg)
159
 			return
161
 			return
160
 		text = 'JOIN RAID DETECTED\n\n' + \
162
 		text = 'JOIN RAID DETECTED\n\n' + \
161
 			'The following members joined in close succession:\n'
163
 			'The following members joined in close succession:\n'
162
-		for member in context.join_members:
164
+		max_members: int = CONFIG['max_members_per_message']
165
+		for member in context.join_members[:max_members]:
163
 			text += '\n• '
166
 			text += '\n• '
164
 			if member in context.banned_members:
167
 			if member in context.banned_members:
165
 				text += f'~~{member.mention} ({member.id})~~ - banned'
168
 				text += f'~~{member.mention} ({member.id})~~ - banned'
167
 				text += f'~~{member.mention} ({member.id})~~ - kicked'
170
 				text += f'~~{member.mention} ({member.id})~~ - kicked'
168
 			else:
171
 			else:
169
 				text += f'{member.mention} ({member.id})'
172
 				text += f'{member.mention} ({member.id})'
173
+		if len(context.join_members) > max_members:
174
+			text += f'\n• {len(context.join_members) - max_members} more'
170
 		text += '\n_(list updates automatically)_'
175
 		text += '\n_(list updates automatically)_'
171
 		await bot_message.set_text(text)
176
 		await bot_message.set_text(text)
172
 		member_count = len(context.join_members)
177
 		member_count = len(context.join_members)

+ 2
- 1
rocketbot/cogs/patterncog.py Bestand weergeven

138
 				message_type = BotMessage.TYPE_INFO
138
 				message_type = BotMessage.TYPE_INFO
139
 				action_descriptions.append('Message logged')
139
 				action_descriptions.append('Message logged')
140
 			elif action.action == 'modwarn':
140
 			elif action.action == 'modwarn':
141
-				should_post_message = True
141
+				should_post_message = not self.was_warned_recently(message.author)
142
 				message_type = BotMessage.TYPE_MOD_WARNING
142
 				message_type = BotMessage.TYPE_MOD_WARNING
143
 				action_descriptions.append('Mods alerted')
143
 				action_descriptions.append('Mods alerted')
144
 			elif action.action == 'reply':
144
 			elif action.action == 'reply':
155
 					('\n• '.join(action_descriptions)),
155
 					('\n• '.join(action_descriptions)),
156
 				type=message_type,
156
 				type=message_type,
157
 				context=context)
157
 				context=context)
158
+			self.record_warning(message.author)
158
 			bm.quote = discordutils.remove_markdown(message.clean_content)
159
 			bm.quote = discordutils.remove_markdown(message.clean_content)
159
 			await bm.set_reactions(BotMessageReaction.standard_set(
160
 			await bm.set_reactions(BotMessageReaction.standard_set(
160
 				did_delete=context.is_deleted,
161
 				did_delete=context.is_deleted,

+ 4
- 2
rocketbot/cogs/urlspamcog.py Bestand weergeven

85
 			context = URLSpamContext(message)
85
 			context = URLSpamContext(message)
86
 			needs_attention = False
86
 			needs_attention = False
87
 			if action == 'modwarn':
87
 			if action == 'modwarn':
88
-				needs_attention = True
88
+				needs_attention = not self.was_warned_recently(message.author)
89
 				self.log(message.guild, f'New user {message.author.name} ' + \
89
 				self.log(message.guild, f'New user {message.author.name} ' + \
90
 					f'({message.author.id}) posted URL {join_age_str} after ' + \
90
 					f'({message.author.id}) posted URL {join_age_str} after ' + \
91
-					'joining. Mods alerted.')
91
+					'joining.' + (' Mods alerted.' if needs_attention else ''))
92
 			elif action == 'delete':
92
 			elif action == 'delete':
93
 				await message.delete()
93
 				await message.delete()
94
 				context.is_deleted = True
94
 				context.is_deleted = True
126
 				did_kick=context.is_kicked,
126
 				did_kick=context.is_kicked,
127
 				did_ban=context.is_banned))
127
 				did_ban=context.is_banned))
128
 			await self.post_message(bm)
128
 			await self.post_message(bm)
129
+			if needs_attention:
130
+				self.record_warning(message.author)
129
 
131
 
130
 	async def on_mod_react(self,
132
 	async def on_mod_react(self,
131
 			bot_message: BotMessage,
133
 			bot_message: BotMessage,

+ 2
- 1
rocketbot/cogs/usernamecog.py Bestand weergeven

156
 			member.guild,
156
 			member.guild,
157
 			f'User {member.mention} ({str(member.id)}, {member.display_name}) has ' +
157
 			f'User {member.mention} ({str(member.id)}, {member.display_name}) has ' +
158
 			f'username matching pattern `{pattern}`.',
158
 			f'username matching pattern `{pattern}`.',
159
-			BotMessage.TYPE_MOD_WARNING,
159
+			BotMessage.TYPE_INFO if self.was_warned_recently(member) else BotMessage.TYPE_MOD_WARNING,
160
 			context)
160
 			context)
161
+		self.record_warning(member)
161
 		await bm.set_reactions(context.reactions())
162
 		await bm.set_reactions(context.reactions())
162
 		await self.post_message(bm)
163
 		await self.post_message(bm)
163
 
164
 

Laden…
Annuleren
Opslaan