Преглед на файлове

JoinRaidCog refactored. Cleanup to all cogs.

tags/1.0.1
Rocketsoup преди 4 години
родител
ревизия
a174b0cca4
променени са 8 файла, в които са добавени 400 реда и са изтрити 580 реда
  1. 100
    7
      cogs/basecog.py
  2. 8
    7
      cogs/configcog.py
  3. 104
    72
      cogs/crosspostcog.py
  4. 128
    472
      cogs/joinraidcog.py
  5. 12
    2
      cogs/patterncog.py
  6. 37
    16
      cogs/urlspamcog.py
  7. 4
    2
      config.py.sample
  8. 7
    2
      rocketbot.py

+ 100
- 7
cogs/basecog.py Целия файл

@@ -280,6 +280,7 @@ class BotMessage:
280 280
 class CogSetting:
281 281
 	def __init__(self,
282 282
 			name: str,
283
+			datatype,
283 284
 			brief: str = None,
284 285
 			description: str = None,
285 286
 			usage: str = None,
@@ -287,6 +288,7 @@ class CogSetting:
287 288
 			max_value = None,
288 289
 			enum_values: set = None):
289 290
 		self.name = name
291
+		self.datatype = datatype
290 292
 		self.brief = brief
291 293
 		self.description = description or ''  # XXX: Can't be None
292 294
 		self.usage = usage
@@ -328,9 +330,13 @@ class BaseCog(commands.Cog):
328 330
 	def add_setting(self, setting: CogSetting) -> None:
329 331
 		"""
330 332
 		Called by a subclass in __init__ to register a mod-configurable
331
-		guild setting. A "get" and "set" command will be generated. If the cog
332
-		has a command group it will be detected automatically and the commands
333
-		added to that. Otherwise the commands will be added at the top level.
333
+		guild setting. A "get" and "set" command will be generated. If the
334
+		setting is named "enabled" (exactly) then "enable" and "disable"
335
+		commands will be created instead which set the setting to True/False.
336
+
337
+		If the cog has a command group it will be detected automatically and
338
+		the commands added to that. Otherwise the commands will be added at
339
+		the top level.
334 340
 		"""
335 341
 		self.settings.append(setting)
336 342
 
@@ -370,8 +376,8 @@ class BaseCog(commands.Cog):
370 376
 
371 377
 	def __set_up_setting_commands(self):
372 378
 		"""
373
-		Sets up getter and setter commands for all registered cog settings.
374
-		Only runs once.
379
+		Sets up commands for editing all registered cog settings. This method
380
+		only runs once.
375 381
 		"""
376 382
 		if self.are_settings_setup:
377 383
 			return
@@ -386,7 +392,10 @@ class BaseCog(commands.Cog):
386 392
 				break
387 393
 
388 394
 		for setting in self.settings:
389
-			self.__make_getter_setter_commands(setting, group)
395
+			if setting.name == 'enabled' or setting.name == 'is_enabled':
396
+				self.__make_enable_disable_commands(setting, group)
397
+			else:
398
+				self.__make_getter_setter_commands(setting, group)
390 399
 
391 400
 	def __make_getter_setter_commands(self,
392 401
 			setting: CogSetting,
@@ -405,9 +414,27 @@ class BaseCog(commands.Cog):
405 414
 		# 	async def getvar(self, context):
406 415
 		async def getter(self, context):
407 416
 			await self.__get_setting_command(context, setting)
408
-		async def setter(self, context, new_value):
417
+		async def setter_int(self, context, new_value: int):
418
+			await self.__set_setting_command(context, new_value, setting)
419
+		async def setter_float(self, context, new_value: float):
420
+			await self.__set_setting_command(context, new_value, setting)
421
+		async def setter_str(self, context, new_value: str):
422
+			await self.__set_setting_command(context, new_value, setting)
423
+		async def setter_bool(self, context, new_value: bool):
409 424
 			await self.__set_setting_command(context, new_value, setting)
410 425
 
426
+		setter = None
427
+		if setting.datatype == int:
428
+			setter = setter_int
429
+		elif setting.datatype == float:
430
+			setter = setter_float
431
+		elif setting.datatype == str:
432
+			setter = setter_str
433
+		elif setting.datatype == bool:
434
+			setter = setter_bool
435
+		else:
436
+			raise RuntimeError(f'Datatype {setting.datatype} unsupported')
437
+
411 438
 		get_command = commands.Command(
412 439
 			getter,
413 440
 			name=f'get{setting.name}',
@@ -440,6 +467,46 @@ class BaseCog(commands.Cog):
440 467
 			self.bot.add_command(get_command)
441 468
 			self.bot.add_command(set_command)
442 469
 
470
+	def __make_enable_disable_commands(self,
471
+			setting: CogSetting,
472
+			group: commands.core.Group) -> None:
473
+		"""
474
+		Creates "enable" and "disable" commands.
475
+		"""
476
+		async def enabler(self, context):
477
+			await self.__enable_command(context, setting)
478
+		async def disabler(self, context):
479
+			await self.__disable_command(context, setting)
480
+
481
+		enable_command = commands.Command(
482
+			enabler,
483
+			name='enable',
484
+			brief=f'Enables {setting.brief}',
485
+			description=setting.description,
486
+			checks=[
487
+				commands.has_permissions(ban_members=True),
488
+				commands.guild_only(),
489
+			])
490
+		disable_command = commands.Command(
491
+			disabler,
492
+			name='disable',
493
+			brief=f'Disables {setting.brief}',
494
+			description=setting.description,
495
+			checks=[
496
+				commands.has_permissions(ban_members=True),
497
+				commands.guild_only(),
498
+			])
499
+
500
+		enable_command.cog = self
501
+		disable_command.cog = self
502
+
503
+		if group:
504
+			group.add_command(enable_command)
505
+			group.add_command(disable_command)
506
+		else:
507
+			self.bot.add_command(enable_command)
508
+			self.bot.add_command(disable_command)
509
+
443 510
 	async def __set_setting_command(self, context, new_value, setting) -> None:
444 511
 		setting_name = setting.name
445 512
 		if context.command.parent:
@@ -465,6 +532,8 @@ class BaseCog(commands.Cog):
465 532
 		await context.message.reply(
466 533
 			f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
467 534
 			mention_author=False)
535
+		await self.on_setting_updated(context.guild, setting)
536
+		self.log(context.guild, f'{context.author.name} set {key} to {new_value}')
468 537
 
469 538
 	async def __get_setting_command(self, context, setting) -> None:
470 539
 		setting_name = setting.name
@@ -482,6 +551,30 @@ class BaseCog(commands.Cog):
482 551
 				f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
483 552
 				mention_author=False)
484 553
 
554
+	async def __enable_command(self, context, setting) -> None:
555
+		key = f'{self.__class__.__name__}.{setting.name}'
556
+		Storage.set_config_value(context.guild, key, True)
557
+		await context.message.reply(
558
+			f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
559
+			mention_author=False)
560
+		await self.on_setting_updated(context.guild, setting)
561
+		self.log(context.guild, f'{context.author.name} enabled {self.__class__.__name__}')
562
+
563
+	async def __disable_command(self, context, setting) -> None:
564
+		key = f'{self.__class__.__name__}.{setting.name}'
565
+		Storage.set_config_value(context.guild, key, False)
566
+		await context.message.reply(
567
+			f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
568
+			mention_author=False)
569
+		await self.on_setting_updated(context.guild, setting)
570
+		self.log(context.guild, f'{context.author.name} disabled {self.__class__.__name__}')
571
+
572
+	async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
573
+		"""
574
+		Subclass override point for being notified when a CogSetting is edited.
575
+		"""
576
+		pass
577
+
485 578
 	# Bot message handling
486 579
 
487 580
 	@classmethod

+ 8
- 7
cogs/configcog.py Целия файл

@@ -24,14 +24,14 @@ class ConfigCog(BaseCog):
24 24
 
25 25
 	@config.command(
26 26
 		name='setwarningchannel',
27
-		brief='Sets the channel where mod warnings are posted',
27
+		brief='Sets where to post mod warnings',
28 28
 		description='Run this command in the channel where bot messages ' +
29
-			'intended for server moderators should be sent. Other bot messages ' +
30
-			'may still be posted in the channel a command was invoked in. If ' +
31
-			'no output channel is set, mod-related messages will not be posted!',
29
+			'intended for server moderators should be sent. Other bot ' +
30
+			'messages may still be posted in the channel a command was ' +
31
+			'invoked in. If no output channel is set, mod-related messages ' +
32
+			'will not be posted!',
32 33
 	)
33 34
 	async def config_setwarningchannel(self, context: commands.Context) -> None:
34
-		'Command handler'
35 35
 		guild: Guild = context.guild
36 36
 		channel: TextChannel = context.channel
37 37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
@@ -45,7 +45,6 @@ class ConfigCog(BaseCog):
45 45
 		brief='Shows the channel where mod warnings are posted',
46 46
 	)
47 47
 	async def config_getwarningchannel(self, context: commands.Context) -> None:
48
-		'Command handler'
49 48
 		guild: Guild = context.guild
50 49
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
51 50
 		if channel_id is None:
@@ -67,7 +66,9 @@ class ConfigCog(BaseCog):
67 66
 			'attention of certain users, be sure to specify a properly ' +
68 67
 			'formed @ tag, not just the name of the user/role.'
69 68
 	)
70
-	async def config_setwarningmention(self, context: commands.Context, mention: str = None) -> None:
69
+	async def config_setwarningmention(self,
70
+			context: commands.Context,
71
+			mention: str = None) -> None:
71 72
 		guild: Guild = context.guild
72 73
 		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
73 74
 		if mention is None:

+ 104
- 72
cogs/crosspostcog.py Целия файл

@@ -1,11 +1,10 @@
1
-from discord import Guild, Member, Message, PartialEmoji
2
-from discord.ext import commands
3 1
 from datetime import datetime, timedelta
4
-import math
2
+from discord import Guild, Member, Message
3
+from discord.ext import commands
5 4
 
5
+from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
6 6
 from config import CONFIG
7 7
 from rscollections import AgeBoundList, SizeBoundDict
8
-from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
9 8
 from storage import Storage
10 9
 
11 10
 class SpamContext:
@@ -19,28 +18,41 @@ class SpamContext:
19 18
 		self.is_autobanned = False
20 19
 		self.spam_messages = set()  # of Message
21 20
 		self.deleted_messages = set()  # of Message
21
+		self.unique_channels = set()  # of TextChannel
22 22
 
23 23
 class CrossPostCog(BaseCog):
24
-	SETTING_WARN_COUNT = CogSetting('warncount',
24
+	"""
25
+	Detects a user posting the same text in multiple channels in a short period
26
+	of time: a common pattern for spammers. Repeated posts in the same channel
27
+	aren't detected, as this can often be for a reason or due to trying a
28
+	failed post when connectivity is poor. Minimum message length can be
29
+	enforced for detection. Minimum is always at least 1 to ignore posts with
30
+	just embeds or images and no text.
31
+	"""
32
+	SETTING_ENABLED = CogSetting('enabled', bool,
33
+		brief='crosspost detection',
34
+		description='Whether crosspost detection is enabled.')
35
+	SETTING_WARN_COUNT = CogSetting('warncount', int,
25 36
 		brief='number of messages to trigger a warning',
26
-		description='The number of identical messages to trigger a mod warning.',
37
+		description='The number of unique channels the same message is ' + \
38
+			'posted in by the same user to trigger a mod warning.',
27 39
 		usage='<count:int>',
28 40
 		min_value=2)
29
-	SETTING_BAN_COUNT = CogSetting('bancount',
41
+	SETTING_BAN_COUNT = CogSetting('bancount', int,
30 42
 		brief='number of messages to trigger a ban',
31
-		description='The number of identical messages to trigger an ' + \
32
-			'automatic ban. Set to a large value to effectively disable, e.g. 9999.',
43
+		description='The number of unique channels the same message is ' + \
44
+			'posted in by the same user to trigger an automatic ban. Set ' + \
45
+			'to a large value to effectively disable, e.g. 9999.',
33 46
 		usage='<count:int>',
34 47
 		min_value=2)
35
-	SETTING_MIN_LENGTH = CogSetting('minlength',
48
+	SETTING_MIN_LENGTH = CogSetting('minlength', int,
36 49
 		brief='minimum message length',
37 50
 		description='The minimum number of characters in a message to be ' + \
38 51
 			'checked for duplicates. This can help ignore common short ' + \
39
-			'messages like "lol" or a single emoji. Set to 0 to count all ' + \
40
-			'message lengths.',
52
+			'messages like "lol" or a single emoji.',
41 53
 		usage='<character_count:int>',
42
-		min_value=0)
43
-	SETTING_TIMESPAN = CogSetting('timespan',
54
+		min_value=1)
55
+	SETTING_TIMESPAN = CogSetting('timespan', float,
44 56
 		brief='time window to look for dupe messages',
45 57
 		description='The number of seconds of message history to look at ' + \
46 58
 			'when looking for duplicates. Shorter values are preferred, ' + \
@@ -48,24 +60,18 @@ class CrossPostCog(BaseCog):
48 60
 		usage='<seconds:int>',
49 61
 		min_value=1)
50 62
 
51
-	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
52
-	STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
53
-
54
-	CONFIG_KEY_WARN_COUNT = "crosspost_warn_count"
55
-	CONFIG_KEY_BAN_COUNT = "crosspost_ban_count"
56
-	CONFIG_KEY_MIN_MESSAGE_LENGTH = "crosspost_min_message_length"
57
-	CONFIG_KEY_MESSAGE_AGE = "crosspost_message_age"
63
+	STATE_KEY_RECENT_MESSAGES = "CrossPostCog.recent_messages"
64
+	STATE_KEY_SPAM_CONTEXT = "CrossPostCog.spam_context"
58 65
 
59 66
 	def __init__(self, bot):
60 67
 		super().__init__(bot)
68
+		self.add_setting(CrossPostCog.SETTING_ENABLED)
61 69
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
62 70
 		self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
63 71
 		self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
64 72
 		self.add_setting(CrossPostCog.SETTING_TIMESPAN)
65 73
 		self.max_spam_contexts = 12
66 74
 
67
-	# Config
68
-
69 75
 	async def __record_message(self, message: Message) -> None:
70 76
 		if message.author.permissions_in(message.channel).ban_members:
71 77
 			# User exempt from spam detection
@@ -74,34 +80,43 @@ class CrossPostCog(BaseCog):
74 80
 			# Message too short to count towards spam total
75 81
 			return
76 82
 		max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
77
-		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) \
78
-			or AgeBoundList(max_age, lambda index, message : message.created_at)
83
+		warn_count = self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT)
84
+		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES)
85
+		if recent_messages is None:
86
+			recent_messages = AgeBoundList(max_age, lambda index, message : message.created_at)
87
+			Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
79 88
 		recent_messages.max_age = max_age
80 89
 		recent_messages.append(message)
81
-		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
82 90
 
83 91
 		# Get all recent messages by user
84 92
 		member_messages = [m for m in recent_messages if m.author.id == message.author.id]
85
-		if len(member_messages) < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
93
+		if len(member_messages) < warn_count:
86 94
 			return
87 95
 
88 96
 		# Look for repeats
89
-		hash_to_count = {}
97
+		hash_to_channels = {}  # int --> set(TextChannel)
90 98
 		max_count = 0
91 99
 		for m in member_messages:
92 100
 			key = hash(m.content)
93
-			count = (hash_to_count.get(key) or 0) + 1
94
-			hash_to_count[key] = count
95
-			max_count = max(max_count, count)
96
-		if max_count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
101
+			channels = hash_to_channels.get(key)
102
+			if channels is None:
103
+				channels = set()
104
+				hash_to_channels[key] = channels
105
+			channels.add(m.channel)
106
+			max_count = max(max_count, len(channels))
107
+		if max_count < warn_count:
97 108
 			return
98 109
 
99 110
 		# Handle the spam
100
-		spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT) \
101
-			or SizeBoundDict(self.max_spam_contexts, lambda key, context : context.age)
102
-		Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
103
-		for message_hash, count in hash_to_count.items():
104
-			if count < self.get_guild_setting(message.guild, self.SETTING_WARN_COUNT):
111
+		spam_lookup = Storage.get_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT)
112
+		if spam_lookup is None:
113
+			spam_lookup = SizeBoundDict(
114
+				self.max_spam_contexts,
115
+				lambda key, context : context.age)
116
+			Storage.set_state_value(message.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
117
+		for message_hash, channels in hash_to_channels.items():
118
+			channel_count = len(channels)
119
+			if channel_count < warn_count:
105 120
 				continue
106 121
 			key = f'{message.author.id}|{message_hash}'
107 122
 			context = spam_lookup.get(key)
@@ -110,21 +125,34 @@ class CrossPostCog(BaseCog):
110 125
 				context = SpamContext(message.author, message_hash)
111 126
 				spam_lookup[key] = context
112 127
 				context.age = message.created_at
128
+				self.log(message.guild,
129
+					f'\u0007{message.author.name} ({message.author.id}) ' + \
130
+					f'posted the same message in {channel_count} or more channels.')
113 131
 			for m in member_messages:
114 132
 				if hash(m.content) == message_hash:
115 133
 					context.spam_messages.add(m)
134
+					context.unique_channels.add(m.channel)
116 135
 			await self.__update_from_context(context)
117 136
 
118 137
 	async def __update_from_context(self, context: SpamContext):
119
-		if len(context.spam_messages) >= self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT):
138
+		ban_count = self.get_guild_setting(context.member.guild, self.SETTING_BAN_COUNT)
139
+		channel_count = len(context.unique_channels)
140
+		if channel_count >= ban_count:
120 141
 			if not context.is_banned:
121 142
 				count = len(context.spam_messages)
122
-				await context.member.ban(reason=f'Autobanned by Rocketbot for posting same message {count} times', delete_message_days=1)
143
+				await context.member.ban(
144
+					reason='Rocketbot: Posted same message in ' + \
145
+						f'{channel_count} channels. Banned by ' + \
146
+						f'{self.bot.user.name}.',
147
+					delete_message_days=1)
123 148
 				context.is_kicked = True
124 149
 				context.is_banned = True
125 150
 				context.is_autobanned = True
126 151
 				context.deleted_messages |= context.spam_messages
127
-				self.log(context.member.guild, f'Bot autobanned {context.member.name} ({context.member.id}) for spamming')
152
+				self.log(context.member.guild,
153
+					f'{context.member.name} ({context.member.id}) posted ' + \
154
+					f'same message in {channel_count} channels. Banned by ' + \
155
+					f'{self.bot.user.name}.')
128 156
 			else:
129 157
 				# Already banned. Nothing to update in the message.
130 158
 				return
@@ -133,7 +161,8 @@ class CrossPostCog(BaseCog):
133 161
 	async def __update_message_from_context(self, context: SpamContext) -> None:
134 162
 		first_spam_message = next(iter(context.spam_messages))
135 163
 		spam_count = len(context.spam_messages)
136
-		deleted_count = len(context.deleted_messages)
164
+		channel_count = len(context.unique_channels)
165
+		deleted_count = len(context.spam_messages)
137 166
 		message = context.bot_message
138 167
 		if message is None:
139 168
 			message = BotMessage(context.member.guild, '',
@@ -141,13 +170,13 @@ class CrossPostCog(BaseCog):
141 170
 			message.quote = first_spam_message.content
142 171
 		if context.is_autobanned:
143 172
 			text = f'User {context.member.mention} auto banned for ' + \
144
-				f'posting the same message {deleted_count} ' + \
145
-				'times. Messages from past 24 hours deleted.'
173
+				f'posting the same message in {channel_count} channels. ' + \
174
+				'Messages from past 24 hours deleted.'
146 175
 			await message.set_reactions([])
147 176
 			await message.set_text(text)
148 177
 		else:
149 178
 			await message.set_text(f'User {context.member.mention} posted ' +
150
-				f'the same message {spam_count} times.')
179
+				f'the same message in {channel_count} channels.')
151 180
 			await message.set_reactions(BotMessageReaction.standard_set(
152 181
 				did_delete = deleted_count >= spam_count,
153 182
 				message_count = spam_count,
@@ -157,27 +186,6 @@ class CrossPostCog(BaseCog):
157 186
 			await self.post_message(message)
158 187
 			context.bot_message = message
159 188
 
160
-	async def __delete_messages(self, context: SpamContext) -> None:
161
-		for message in context.spam_messages - context.deleted_messages:
162
-			await message.delete()
163
-			context.deleted_messages.add(message)
164
-		await self.__update_from_context(context)
165
-		self.log(context.member.guild, f'Mod deleted messages from {context.member.name} ({context.member.id})')
166
-
167
-	async def __kick(self, context: SpamContext) -> None:
168
-		await context.member.kick(reason='Posting same message repeatedly')
169
-		context.is_kicked = True
170
-		await self.__update_from_context(context)
171
-		self.log(context.member.guild, f'Mod kicked user {context.member.name} ({context.member.id})')
172
-
173
-	async def __ban(self, context: SpamContext) -> None:
174
-		await context.member.ban(reason='Posting same message repeatedly', delete_message_days=1)
175
-		context.deleted_messages |= context.spam_messages
176
-		context.is_kicked = True
177
-		context.is_banned = True
178
-		await self.__update_from_context(context)
179
-		self.log(context.member.guild, f'Mod banned user {context.member.name} ({context.member.id})')
180
-
181 189
 	async def on_mod_react(self,
182 190
 			bot_message: BotMessage,
183 191
 			reaction: BotMessageReaction,
@@ -186,12 +194,36 @@ class CrossPostCog(BaseCog):
186 194
 		if context is None:
187 195
 			return
188 196
 
197
+		channel_count = len(context.unique_channels)
189 198
 		if reaction.emoji == CONFIG['trash_emoji']:
190
-			await self.__delete_messages(context)
199
+			for message in context.spam_messages - context.deleted_messages:
200
+				await message.delete()
201
+				context.deleted_messages.add(message)
202
+			await self.__update_from_context(context)
203
+			self.log(context.member.guild,
204
+				f'{context.member.name} ({context.member.id}) posted same ' + \
205
+				f'message in {channel_count} channels. Deleted by {reacted_by.name}.')
191 206
 		elif reaction.emoji == CONFIG['kick_emoji']:
192
-			await self.__kick(context)
207
+			await context.member.kick(
208
+				reason=f'Rocketbot: Posted same message in {channel_count} ' + \
209
+					f'channels. Kicked by {reacted_by.name}.')
210
+			context.is_kicked = True
211
+			await self.__update_from_context(context)
212
+			self.log(context.member.guild,
213
+				f'{context.member.name} ({context.member.id}) posted same ' + \
214
+				f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
193 215
 		elif reaction.emoji == CONFIG['ban_emoji']:
194
-			await self.__ban(context)
216
+			await context.member.ban(
217
+				reason=f'Rocketbot: Posted same message in {channel_count} ' + \
218
+					f'channels. Banned by {reacted_by.name}.',
219
+				delete_message_days=1)
220
+			context.deleted_messages |= context.spam_messages
221
+			context.is_kicked = True
222
+			context.is_banned = True
223
+			await self.__update_from_context(context)
224
+			self.log(context.member.guild,
225
+				f'{context.member.name} ({context.member.id}) posted same ' + \
226
+				f'message in {channel_count} channels. Kicked by {reacted_by.name}.')
195 227
 
196 228
 	@commands.Cog.listener()
197 229
 	async def on_message(self, message: Message):
@@ -202,16 +234,16 @@ class CrossPostCog(BaseCog):
202 234
 				message.content is None or \
203 235
 				message.content == '':
204 236
 			return
237
+		if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
238
+			return
205 239
 		await self.__record_message(message)
206 240
 
207
-	# -- Commands -----------------------------------------------------------
208
-
209 241
 	@commands.group(
210
-		brief='Manages crosspost/repeated post detection and handling',
242
+		brief='Manages crosspost detection and handling',
211 243
 	)
212 244
 	@commands.has_permissions(ban_members=True)
213 245
 	@commands.guild_only()
214 246
 	async def crosspost(self, context: commands.Context):
215
-		'Command group'
247
+		'Crosspost detection command group'
216 248
 		if context.invoked_subcommand is None:
217 249
 			await context.send_help()

+ 128
- 472
cogs/joinraidcog.py Целия файл

@@ -1,242 +1,53 @@
1
-from discord import Guild, Intents, Member, Message, PartialEmoji, RawReactionActionEvent
1
+from datetime import datetime, timedelta
2
+from discord import Guild, Member
2 3
 from discord.ext import commands
3
-from storage import Storage
4
-from cogs.basecog import BaseCog
5
-from config import CONFIG
6
-from datetime import datetime
7
-
8
-class JoinRecord:
9
-	"""
10
-	Data object containing details about a single guild join event.
11
-	"""
12
-	def __init__(self, member: Member):
13
-		self.member = member
14
-		self.join_time = member.joined_at or datetime.now()
15
-		# These flags only track whether this bot has kicked/banned
16
-		self.is_kicked = False
17
-		self.is_banned = False
18
-
19
-	def age_seconds(self, now: datetime) -> float:
20
-		"""
21
-		Returns the age of this join in seconds from the given "now" time.
22
-		"""
23
-		a = now - self.join_time
24
-		return float(a.total_seconds())
25
-
26
-class RaidPhase:
27
-	"""
28
-	Enum of phases in a JoinRaidRecord. Phases progress monotonically.
29
-	"""
30
-	NONE = 0
31
-	JUST_STARTED = 1
32
-	CONTINUING = 2
33
-	ENDED = 3
34
-
35
-class JoinRaidRecord:
36
-	"""
37
-	Tracks recent joins to a guild to detect join raids, where a large number
38
-	of automated users all join at the same time. Manages list of joins to not
39
-	grow unbounded.
40
-	"""
41
-	def __init__(self):
42
-		self.joins = []
43
-		self.phase = RaidPhase.NONE
44
-		# datetime when the raid started, or None.
45
-		self.raid_start_time = None
46
-		# Message posted to Discord to warn of the raid. Convenience property
47
-		# managed by caller. Ignored by this class.
48
-		self.warning_message = None
49
-
50
-	def handle_join(self,
51
-			member: Member,
52
-			now: datetime,
53
-			max_join_count: int,
54
-			max_age_seconds: float) -> None:
55
-		"""
56
-		Processes a new member join to a guild and detects join raids. Updates
57
-		self.phase and self.raid_start_time properties.
58
-		"""
59
-		# Check for existing record for this user
60
-		join: JoinRecord = None
61
-		i: int = 0
62
-		while i < len(self.joins):
63
-			elem = self.joins[i]
64
-			if elem.member.id == member.id:
65
-				join = self.joins.pop(i)
66
-				join.join_time = now
67
-				break
68
-			i += 1
69
-		# Add new record to end
70
-		self.joins.append(join or JoinRecord(member))
71
-		# Check raid status and do upkeep
72
-		self.__process_joins(now, max_age_seconds, max_join_count)
73
-
74
-	def __process_joins(self,
75
-			now: datetime,
76
-			max_age_seconds: float,
77
-			max_join_count: int) -> None:
78
-		"""
79
-		Processes self.joins after each addition, detects raids, updates
80
-		self.phase, and throws out unneeded records.
81
-		"""
82
-		i: int = 0
83
-		recent_count: int = 0
84
-		should_cull: bool = self.phase == RaidPhase.NONE
85
-		while i < len(self.joins):
86
-			join: JoinRecord = self.joins[i]
87
-			age: float = join.age_seconds(now)
88
-			is_old: bool = age > max_age_seconds
89
-			if not is_old:
90
-				recent_count += 1
91
-			if is_old and should_cull:
92
-				self.joins.pop(i)
93
-			else:
94
-				i += 1
95
-		is_raid = recent_count > max_join_count
96
-		if is_raid:
97
-			if self.phase == RaidPhase.NONE:
98
-				self.phase = RaidPhase.JUST_STARTED
99
-				self.raid_start_time = now
100
-			elif self.phase == RaidPhase.JUST_STARTED:
101
-				self.phase = RaidPhase.CONTINUING
102
-		elif self.phase == self.phase in (RaidPhase.JUST_STARTED, RaidPhase.CONTINUING):
103
-			self.phase = RaidPhase.ENDED
104
-
105
-		# Undo join add if the raid is over
106
-		if self.phase == RaidPhase.ENDED and len(self.joins) > 0:
107
-			last = self.joins.pop(-1)
108
-
109
-	async def kick_all(self,
110
-			reason: str = "Part of join raid") -> list[Member]:
111
-		"""
112
-		Kicks all users in this join raid. Skips users who have already been
113
-		flagged as having been kicked or banned. Returns a List of Members
114
-		who were newly kicked.
115
-		"""
116
-		kicks = []
117
-		guild = None
118
-		for join in self.joins:
119
-			guild = join.member.guild
120
-			if join.is_kicked or join.is_banned:
121
-				continue
122
-			await join.member.kick(reason=reason)
123
-			join.is_kicked = True
124
-			kicks.append(join.member)
125
-		self.phase = RaidPhase.ENDED
126
-		if len(kicks) > 0:
127
-			self.log(guild, f'Mod kicked {len(kicks)} people')
128
-		return kicks
129
-
130
-	async def ban_all(self,
131
-			reason: str = "Part of join raid",
132
-			delete_message_days: int = 0) -> list[Member]:
133
-		"""
134
-		Bans all users in this join raid. Skips users who have already been
135
-		flagged as having been banned. Users who were previously kicked can
136
-		still be banned. Returns a List of Members who were newly banned.
137
-		"""
138
-		bans = []
139
-		guild = None
140
-		for join in self.joins:
141
-			guild = join.member.guild
142
-			if join.is_banned:
143
-				continue
144
-			await join.member.ban(
145
-				reason=reason,
146
-				delete_message_days=delete_message_days)
147
-			join.is_banned = True
148
-			bans.append(join.member)
149
-		self.phase = RaidPhase.ENDED
150
-		if len(bans) > 0:
151
-			self.log(guild, f'Mod banned {len(bans)} people')
152
-		return bans
4
+import weakref
153 5
 
154
-class GuildContext:
155
-	"""
156
-	Logic and state for a single guild serviced by the bot.
157
-	"""
158
-	def __init__(self, guild_id: int):
159
-		self.guild_id = guild_id
160
-		# Non-persisted runtime state
161
-		self.current_raid = JoinRaidRecord()
162
-		self.all_raids = [ self.current_raid ] # periodically culled of old ones
163
-
164
-	def reset_raid(self, now: datetime):
165
-		"""
166
-		Retires self.current_raid and creates a new empty one.
167
-		"""
168
-		self.current_raid = JoinRaidRecord()
169
-		self.all_raids.append(self.current_raid)
170
-		self.__cull_old_raids(now)
171
-
172
-	def find_raid_for_message_id(self, message_id: int) -> JoinRaidRecord:
173
-		"""
174
-		Retrieves a JoinRaidRecord instance for the given raid warning message.
175
-		Returns None if not found.
176
-		"""
177
-		for raid in self.all_raids:
178
-			if raid.warning_message is not None and raid.warning_message.id == message_id:
179
-				return raid
180
-		return None
6
+from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
7
+from config import CONFIG
8
+from rscollections import AgeBoundList
9
+from storage import Storage
181 10
 
182
-	def __cull_old_raids(self, now: datetime):
183
-		"""
184
-		Gets rid of old JoinRaidRecord records from self.all_raids that are too
185
-		old to still be useful.
186
-		"""
187
-		i: int = 0
188
-		while i < len(self.all_raids):
189
-			raid = self.all_raids[i]
190
-			if raid == self.current_raid:
191
-				i += 1
192
-				continue
193
-			age_seconds = float((raid.raid_start_time - now).total_seconds())
194
-			if age_seconds > 86400.0:
195
-				self.__trace('Culling old raid')
196
-				self.all_raids.pop(i)
197
-			else:
198
-				i += 1
11
+class JoinRaidContext:
12
+	def __init__(self, join_members: list):
13
+		self.join_members = list(join_members)
14
+		self.kicked_members = set()
15
+		self.banned_members = set()
16
+		self.warning_message_ref = None
199 17
 
200
-	def __trace(self, message):
201
-		"""
202
-		Debugging trace.
203
-		"""
204
-		print(f'{self.guild_id}: {message}')
18
+	def last_join_time(self) -> datetime:
19
+		return self.join_members[-1].joined_at
205 20
 
206 21
 class JoinRaidCog(BaseCog):
207 22
 	"""
208 23
 	Cog for monitoring member joins and detecting potential bot raids.
209 24
 	"""
210
-	MIN_JOIN_COUNT = 2
211
-
212
-	STATE_KEY_RAID_COUNT = 'joinraid_count'
213
-	STATE_KEY_RAID_SECONDS = 'joinraid_seconds'
214
-	STATE_KEY_ENABLED = 'joinraid_enabled'
25
+	SETTING_ENABLED = CogSetting('enabled', bool,
26
+			brief='join raid detection',
27
+			description='Whether this cog is enabled for a guild.')
28
+	SETTING_JOIN_COUNT = CogSetting('joincount', int,
29
+			brief='number of joins to trigger a warning',
30
+			description='The number of joins occuring within the time ' + \
31
+				'window to trigger a mod warning.',
32
+			usage='<count:int>',
33
+			min_value=2)
34
+	SETTING_JOIN_TIME = CogSetting('jointime', float,
35
+			brief='time window length to look for joins',
36
+			description='The number of seconds of join history to look ' + \
37
+				'at when counting recent joins. If joincount or more ' + \
38
+				'joins occur within jointime seconds a mod warning is issued.',
39
+			usage='<seconds:float>',
40
+			min_value=1.0,
41
+			max_value=900.0)
42
+
43
+	STATE_KEY_RECENT_JOINS = "JoinRaidCog.recent_joins"	
44
+	STATE_KEY_LAST_RAID = "JoinRaidCog.last_raid"
215 45
 
216 46
 	def __init__(self, bot):
217 47
 		super().__init__(bot)
218
-		self.guild_id_to_context = {}  # Guild.id -> GuildContext
219
-
220
-	# -- Config -------------------------------------------------------------
221
-
222
-	def __get_raid_rate(self, guild: Guild) -> tuple:
223
-		"""
224
-		Returns the join rate configured for this guild.
225
-		"""
226
-		count: int = Storage.get_config_value(guild, self.STATE_KEY_RAID_COUNT) \
227
-			or self.get_cog_default('warning_count')
228
-		seconds: float = Storage.get_config_value(guild, self.STATE_KEY_RAID_SECONDS) \
229
-			or self.get_cog_default('warning_seconds')
230
-		return (count, seconds)
231
-
232
-	def __is_enabled(self, guild: Guild) -> bool:
233
-		"""
234
-		Returns whether join raid detection is enabled in this guild.
235
-		"""
236
-		return Storage.get_config_value(guild, self.STATE_KEY_ENABLED) \
237
-			or self.get_cog_default('enabled')
238
-
239
-	# -- Commands -----------------------------------------------------------
48
+		self.add_setting(JoinRaidCog.SETTING_ENABLED)
49
+		self.add_setting(JoinRaidCog.SETTING_JOIN_COUNT)
50
+		self.add_setting(JoinRaidCog.SETTING_JOIN_TIME)
240 51
 
241 52
 	@commands.group(
242 53
 		brief='Manages join raid detection and handling',
@@ -248,257 +59,102 @@ class JoinRaidCog(BaseCog):
248 59
 		if context.invoked_subcommand is None:
249 60
 			await context.send_help()
250 61
 
251
-	@joinraid.command(
252
-		name='enable',
253
-		brief='Enables join raid detection',
254
-		description='Join raid detection is off by default.',
255
-	)
256
-	async def joinraid_enable(self, context: commands.Context):
257
-		'Command handler'
258
-		guild = context.guild
259
-		Storage.set_config_value(guild, self.STATE_KEY_ENABLED, True)
260
-		# TODO: Startup tracking if necessary
261
-		await context.message.reply(
262
-			CONFIG['success_emoji'] + ' ' +
263
-			self.__describe_raid_settings(guild, force_enabled_status=True),
264
-			mention_author=False)
265
-
266
-	@joinraid.command(
267
-		name='disable',
268
-		brief='Disables join raid detection',
269
-		description='Join raid detection is off by default.',
270
-	)
271
-	async def joinraid_disable(self, context: commands.Context):
272
-		'Command handler'
273
-		guild = context.guild
274
-		Storage.set_config_value(guild, self.STATE_KEY_ENABLED, False)
275
-		# TODO: Tear down tracking if necessary
276
-		await context.message.reply(
277
-			CONFIG['success_emoji'] + ' ' +
278
-			self.__describe_raid_settings(guild, force_enabled_status=True),
279
-			mention_author=False)
280
-
281
-	@joinraid.command(
282
-		name='setrate',
283
-		brief='Sets the rate of joins which triggers a warning to mods',
284
-		description='Each time a member joins, the join records from the ' +
285
-			'previous _x_ seconds are counted up, where _x_ is the number of ' +
286
-			'seconds configured by this command. If that count meets or ' +
287
-			'exceeds the maximum join count configured by this command then ' +
288
-			'a raid is detected and a warning is issued to the mods.',
289
-		usage='<join_count:int> <seconds:float>',
290
-	)
291
-	async def joinraid_setrate(self, context: commands.Context,
292
-			join_count: int,
293
-			seconds: float):
294
-		'Command handler'
295
-		guild = context.guild
296
-		if join_count < self.MIN_JOIN_COUNT:
297
-			await context.message.reply(
298
-				CONFIG['warning_emoji'] + ' ' +
299
-				f'`join_count` must be >= {self.MIN_JOIN_COUNT}',
300
-				mention_author=False)
301
-			return
302
-		if seconds <= 0:
303
-			await context.message.reply(
304
-				CONFIG['warning_emoji'] + ' ' +
305
-				f'`seconds` must be > 0',
306
-				mention_author=False)
307
-			return
308
-		Storage.set_config_values(guild, {
309
-			self.STATE_KEY_RAID_COUNT: join_count,
310
-			self.STATE_KEY_RAID_SECONDS: seconds,
311
-		})
312
-
313
-		await context.message.reply(
314
-			CONFIG['success_emoji'] + ' ' +
315
-			self.__describe_raid_settings(guild, force_rate_status=True),
316
-			mention_author=False)
317
-
318
-	@joinraid.command(
319
-		name='getrate',
320
-		brief='Shows the rate of joins which triggers a warning to mods',
321
-	)
322
-	async def joinraid_getrate(self, context: commands.Context):
323
-		'Command handler'
324
-		await context.message.reply(
325
-			CONFIG['info_emoji'] + ' ' +
326
-			self.__describe_raid_settings(context.guild, force_rate_status=True),
327
-			mention_author=False)
328
-
329
-	# -- Listeners ----------------------------------------------------------
330
-
331
-	@commands.Cog.listener()
332
-	async def on_raw_reaction_add(self, payload: RawReactionActionEvent):
333
-		'Event handler'
334
-		if payload.user_id == self.bot.user.id:
335
-			# Ignore bot's own reactions
336
-			return
337
-		member: Member = payload.member
338
-		if member is None:
339
-			return
340
-		guild: Guild = self.bot.get_guild(payload.guild_id)
341
-		if guild is None:
342
-			# Possibly a DM
343
-			return
344
-		channel: GuildChannel = guild.get_channel(payload.channel_id)
345
-		if channel is None:
346
-			# Possibly a DM
347
-			return
348
-		message: Message = await channel.fetch_message(payload.message_id)
349
-		if message is None:
350
-			# Message deleted?
351
-			return
352
-		if message.author.id != self.bot.user.id:
353
-			# Bot didn't author this
354
-			return
355
-		if not member.permissions_in(channel).ban_members:
356
-			# Not a mod
357
-			# TODO: Remove reaction?
358
-			return
359
-		gc: GuildContext = self.__get_guild_context(guild)
360
-		raid: JoinRaidRecord = gc.find_raid_for_message_id(payload.message_id)
361
-		if raid is None:
362
-			# Either not a warning message or one we stopped tracking
363
-			return
364
-		emoji: PartialEmoji = payload.emoji
365
-		if emoji.name == CONFIG['kick_emoji']:
366
-			await raid.kick_all()
367
-			gc.reset_raid(message.created_at)
368
-			await self.__update_raid_warning(guild, raid)
369
-		elif emoji.name == CONFIG['ban_emoji']:
370
-			await raid.ban_all()
371
-			gc.reset_raid(message.created_at)
372
-			await self.__update_raid_warning(guild, raid)
62
+	async def on_mod_react(self,
63
+			bot_message: BotMessage,
64
+			reaction: BotMessageReaction,
65
+			reacted_by: Member) -> None:
66
+		guild: Guild = bot_message.guild
67
+		raid: JoinRaidRecord = bot_message.context
68
+		if reaction.emoji == CONFIG['kick_emoji']:
69
+			to_kick = set(raid.join_members) - raid.kicked_members
70
+			for member in to_kick:
71
+				await member.kick(
72
+					reason=f'Rocketbot: Part of join raid. Kicked by {reacted_by.name}.')
73
+			raid.kicked_members |= to_kick
74
+			await self.__update_warning_message(raid)
75
+			self.log(guild, f'Join raid users kicked by {reacted_by.name}.')
76
+		elif reaction.emoji == CONFIG['ban_emoji']:
77
+			to_ban = set(raid.join_members) - raid.banned_members
78
+			for member in to_ban:
79
+				await member.ban(
80
+					reason=f'Rocketbot: Part of join raid. Banned by {reacted_by.name}.',
81
+					delete_message_days=0)
82
+			raid.banned_members |= to_ban
83
+			await self.__update_warning_message(raid)
84
+			self.log(guild, f'Join raid users banned by {reacted_by.name}')
373 85
 
374 86
 	@commands.Cog.listener()
375 87
 	async def on_member_join(self, member: Member) -> None:
376 88
 		'Event handler'
377 89
 		guild: Guild = member.guild
378
-		if not self.__is_enabled(guild):
90
+		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
379 91
 			return
380
-		(count, seconds) = self.__get_raid_rate(guild)
381
-		now = member.joined_at
382
-		gc: GuildContext = self.__get_guild_context(guild)
383
-		raid: JoinRaidRecord = gc.current_raid
384
-		raid.handle_join(member, now, count, seconds)
385
-		if raid.phase == RaidPhase.JUST_STARTED:
386
-			await self.__post_raid_warning(guild, raid)
387
-		elif raid.phase == RaidPhase.CONTINUING:
388
-			await self.__update_raid_warning(guild, raid)
389
-		elif raid.phase == RaidPhase.ENDED:
390
-			# First join that occurred too late to be part of last raid. Join
391
-			# not added. Start a new raid record and add it there.
392
-			gc.reset_raid(now)
393
-			gc.current_raid.handle_join(member, now, count, seconds)
394
-
395
-	# -- Misc ---------------------------------------------------------------
396
-
397
-	def __describe_raid_settings(self,
398
-			guild: Guild,
399
-			force_enabled_status=False,
400
-			force_rate_status=False) -> str:
401
-		"""
402
-		Creates a Discord message describing the current join raid settings.
403
-		"""
404
-		enabled = self.__is_enabled(guild)
405
-		(count, seconds) = self.__get_raid_rate(guild)
406
-
407
-		sentences = []
408
-
409
-		if enabled or force_rate_status:
410
-			sentences.append(f'Join raids will be detected at {count} or more joins per {seconds} seconds.')
411
-
412
-		if enabled and force_enabled_status:
413
-				sentences.append('Raid detection enabled.')
414
-		elif not enabled:
415
-			sentences.append('Raid detection disabled.')
416
-
417
-		tips = []
418
-		if enabled or force_rate_status:
419
-			tips.append('• Use `setrate` subcommand to change detection threshold')
420
-		if enabled:
421
-			tips.append('• Use `disable` subcommand to disable detection.')
92
+		min_count = self.get_guild_setting(guild, self.SETTING_JOIN_COUNT)
93
+		seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
94
+		timespan: timedelta = timedelta(seconds=seconds)
95
+
96
+		last_raid: JoinRaidContext = Storage.get_state_value(guild, self.STATE_KEY_LAST_RAID)
97
+		recent_joins: AgeBoundList = Storage.get_state_value(guild, self.STATE_KEY_RECENT_JOINS)
98
+		if recent_joins is None:
99
+			recent_joins = AgeBoundList(timespan, lambda i, member : member.joined_at)
100
+			Storage.set_state_value(guild, self.STATE_KEY_RECENT_JOINS, recent_joins)
101
+		if last_raid:
102
+			if member.joined_at - last_raid.last_join_time() > timespan:
103
+				# Last raid is over
104
+				Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, None)
105
+				recent_joins.append(member)
106
+				return
107
+			# Add join to existing raid
108
+			last_raid.join_members.append(member)
109
+			await self.__update_warning_message(last_raid)
422 110
 		else:
423
-			tips.append('• Use `enable` subcommand to enable detection.')
424
-
425
-		message = ''
426
-		message += ' '.join(sentences)
427
-		if len(tips) > 0:
428
-			message += '\n\n' + ('\n'.join(tips))
429
-		return message
430
-
431
-	def __get_guild_context(self, guild: Guild) -> GuildContext:
432
-		"""
433
-		Looks up the GuildContext for the given Guild or creates a new one if
434
-		one does not yet exist.
435
-		"""
436
-		gc: GuildContext = self.guild_id_to_context.get(guild.id)
437
-		if gc is not None:
438
-			return gc
439
-		gc = GuildContext(guild.id)
440
-		gc.join_warning_count = self.get_cog_default('warning_count')
441
-		gc.join_warning_seconds = self.get_cog_default('warning_seconds')
442
-
443
-		self.guild_id_to_context[guild.id] = gc
444
-		return gc
445
-
446
-	async def __post_raid_warning(self, guild: Guild, raid: JoinRaidRecord) -> None:
447
-		"""
448
-		Posts a warning message about the given raid.
449
-		"""
450
-		(message, can_kick, can_ban) = self.__describe_raid(raid)
451
-		raid.warning_message = await self.warn(guild, message)
452
-		if can_kick:
453
-			await raid.warning_message.add_reaction(CONFIG['kick_emoji'])
454
-		if can_ban:
455
-			await raid.warning_message.add_reaction(CONFIG['ban_emoji'])
456
-		self.log(guild, f'New join raid detected!')
457
-
458
-	async def __update_raid_warning(self, guild: Guild, raid: JoinRaidRecord) -> None:
459
-		"""
460
-		Updates the existing warning message for a raid.
461
-		"""
462
-		if raid.warning_message is None:
111
+			# Add join to the general, non-raid recent join list
112
+			recent_joins.append(member)
113
+			if len(recent_joins) >= min_count:
114
+				self.log(guild, '\u0007Join raid detected')
115
+				last_raid = JoinRaidContext(recent_joins)
116
+				Storage.set_state_value(guild, self.STATE_KEY_LAST_RAID, last_raid)
117
+				recent_joins.clear()
118
+				msg = BotMessage(guild,
119
+						text='',
120
+						type=BotMessage.TYPE_MOD_WARNING,
121
+						context=last_raid)
122
+				last_raid.warning_message_ref = weakref.ref(msg)
123
+				await self.__update_warning_message(last_raid)
124
+				await self.post_message(msg)
125
+
126
+	async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
127
+		if setting is self.SETTING_JOIN_TIME:
128
+			seconds = self.get_guild_setting(guild, self.SETTING_JOIN_TIME)
129
+			timespan: timedelta = timedelta(seconds=seconds)
130
+			recent_joins: AgeBoundList = Storage.get_state_value(guild,
131
+				self.STATE_KEY_RECENT_JOINS)
132
+			if recent_joins:
133
+				recent_joins.max_age = timespan
134
+				recent_joins.purge_old_elements()
135
+
136
+	async def __update_warning_message(self, context: JoinRaidContext) -> None:
137
+		if context.warning_message_ref is None:
463 138
 			return
464
-		(message, can_kick, can_ban) = self.__describe_raid(raid)
465
-		await self.update_warn(raid.warning_message, message)
466
-		if not can_kick:
467
-			await raid.warning_message.clear_reaction(CONFIG['kick_emoji'])
468
-		if not can_ban:
469
-			await raid.warning_message.clear_reaction(CONFIG['ban_emoji'])
470
-
471
-	def __describe_raid(self, raid: JoinRaidRecord) -> tuple:
472
-		"""
473
-		Creates a Discord warning message with details about the given raid.
474
-		Returns a tuple containing the message text, a flag if any users can
475
-		still be kicked, and a flag if anyone can still be banned.
476
-		"""
477
-		message = '🚨 **JOIN RAID DETECTED** 🚨'
478
-		message += '\nThe following members joined in close succession:\n'
479
-		any_kickable = False
480
-		any_bannable = False
481
-		for join in raid.joins:
482
-			message += '\n• '
483
-			if join.is_banned:
484
-				message += '~~' + join.member.mention + '~~ - banned'
485
-			elif join.is_kicked:
486
-				message += '~~' + join.member.mention + '~~ - kicked'
487
-				any_bannable = True
139
+		bot_message = context.warning_message_ref()
140
+		if bot_message is None:
141
+			return
142
+		text = 'JOIN RAID DETECTED\n\n' + \
143
+			'The following members joined in close succession:\n'
144
+		for member in context.join_members:
145
+			text += '\n• '
146
+			if member in context.banned_members:
147
+				text += f'~~{member.mention} ({member.id})~~ - banned'
148
+			elif member in context.kicked_members:
149
+				text += f'~~{member.mention} ({member.id})~~ - kicked'
488 150
 			else:
489
-				message += join.member.mention
490
-				any_bannable = True
491
-				any_kickable = True
492
-		message += '\n_(list updates automatically)_'
493
-
494
-		message += '\n'
495
-		if any_kickable:
496
-			message += f'\nReact to this message with {CONFIG["kick_emoji"]} to kick all these users.'
497
-		else:
498
-			message += '\nNo users left to kick.'
499
-		if any_bannable:
500
-			message += f'\nReact to this message with {CONFIG["ban_emoji"]} to ban all these users.'
501
-		else:
502
-			message += '\nNo users left to ban.'
503
-
504
-		return (message, any_kickable, any_bannable)
151
+				text += f'{member.mention} ({member.id})'
152
+		text += '\n_(list updates automatically)_'
153
+		await bot_message.set_text(text)
154
+		member_count = len(context.join_members)
155
+		kick_count = len(context.kicked_members)
156
+		ban_count = len(context.banned_members)
157
+		await bot_message.set_reactions(BotMessageReaction.standard_set(
158
+			did_kick=kick_count >= member_count,
159
+			did_ban=ban_count >= member_count,
160
+			user_count=member_count))

+ 12
- 2
cogs/patterncog.py Целия файл

@@ -96,13 +96,17 @@ class PatternCog(BaseCog):
96 96
 					await message.author.kick(reason='Rocketbot: Message matched banned pattern')
97 97
 					text = f'Message from {message.author.mention} matched ' + \
98 98
 						'banned pattern. Message deleted and user kicked.'
99
-					self.log(message.guild, 'Message matched pattern. Kicked user.')
99
+					self.log(message.guild,
100
+						'\u0007Message matched pattern. Kicked ' + \
101
+						f'{message.author.name} ({message.author.id}).')
100 102
 				elif pattern.action == 'ban':
101 103
 					await message.delete()
102 104
 					await message.author.ban(reason='Rocketbot: Message matched banned pattern')
103 105
 					text = f'Message from {message.author.mention} matched ' + \
104 106
 						'banned pattern. Message deleted and user banned.'
105
-					self.log(message.guild, 'Message matched pattern. Banned user.')
107
+					self.log(message.guild,
108
+						'\u0007Message matched pattern. Banned ' + \
109
+						f'{message.author_name} ({message.author.id}).')
106 110
 				if text:
107 111
 					m = BotMessage(message.guild,
108 112
 						text = msg,
@@ -143,6 +147,12 @@ class PatternCog(BaseCog):
143 147
 			string
144 148
 			regex
145 149
 			mention
150
+
151
+		Evaluation
152
+			and
153
+			or
154
+			( )
155
+			!( )
146 156
 	"""
147 157
 
148 158
 	@commands.group(

+ 37
- 16
cogs/urlspamcog.py Целия файл

@@ -15,11 +15,19 @@ class URLSpamContext:
15 15
 		self.is_banned = False
16 16
 
17 17
 class URLSpamCog(BaseCog):
18
-	SETTING_ACTION = CogSetting('action',
18
+	"""
19
+	Detects users posting URLs who just joined recently: a common spam pattern.
20
+	Can be configured to take immediate action or just warn the mods.
21
+	"""
22
+
23
+	SETTING_ENABLED = CogSetting('enabled', bool,
24
+			brief='URL spam detection',
25
+			description='Whether URLs posted soon after joining are flagged.')
26
+	SETTING_ACTION = CogSetting('action', str,
19 27
 			brief='action to take on spam',
20 28
 			description='The action to take on detected URL spam.',
21 29
 			enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
22
-	SETTING_JOIN_AGE = CogSetting('joinage',
30
+	SETTING_JOIN_AGE = CogSetting('joinage', float,
23 31
 			brief='seconds since member joined',
24 32
 			description='The minimum seconds since the user joined the ' + \
25 33
 				'server before they can post URLs. URLs posted by users ' + \
@@ -32,6 +40,7 @@ class URLSpamCog(BaseCog):
32 40
 
33 41
 	def __init__(self, bot):
34 42
 		super().__init__(bot)
43
+		self.add_setting(URLSpamCog.SETTING_ENABLED)
35 44
 		self.add_setting(URLSpamCog.SETTING_ACTION)
36 45
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
37 46
 
@@ -55,7 +64,8 @@ class URLSpamCog(BaseCog):
55 64
 			return
56 65
 
57 66
 		action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
58
-		min_join_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE))
67
+		join_seconds = self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE)
68
+		min_join_age = timedelta(seconds=join_seconds)
59 69
 		if action == 'nothing':
60 70
 			return
61 71
 		if not self.__contains_url(message.content):
@@ -68,28 +78,33 @@ class URLSpamCog(BaseCog):
68 78
 			if action == 'modwarn':
69 79
 				needs_attention = True
70 80
 				self.log(message.guild, f'New user {message.author.name} ' + \
71
-					f'({message.author.id}) posted URL. Mods alerted.')
81
+					f'({message.author.id}) posted URL {join_age_str} after ' + \
82
+					'joining. Mods alerted.')
72 83
 			elif action == 'delete':
73 84
 				await message.delete()
74 85
 				context.is_deleted = True
75 86
 				self.log(message.guild, f'New user {message.author.name} ' + \
76
-					f'({message.author.id}) posted URL. Message deleted.')
87
+					f'({message.author.id}) posted URL {join_age_str} after ' + \
88
+					'joining. Message deleted.')
77 89
 			elif action == 'kick':
78 90
 				await message.delete()
79 91
 				context.is_deleted = True
80
-				await message.author.kick(reason='User posted a link ' + \
81
-					f'{join_age_str} after joining')
92
+				await message.author.kick(
93
+					reason=f'Rocketbot: Posted a link {join_age_str} after joining')
82 94
 				context.is_kicked = True
83 95
 				self.log(message.guild, f'New user {message.author.name} ' + \
84
-					f'({message.author.id}) posted URL. User kicked.')
96
+					f'({message.author.id}) posted URL {join_age_str} after ' + \
97
+					'joining. User kicked.')
85 98
 			elif action == 'ban':
86
-				await message.author.ban(reason='User posted a link ' + \
87
-					f'{join_age_str} after joining', delete_message_days=1)
99
+				await message.author.ban(
100
+					reason=f'Rocketbot: User posted a link {join_age_str} after joining',
101
+					delete_message_days=1)
88 102
 				context.is_deleted = True
89 103
 				context.is_kicked = True
90 104
 				context.is_banned = True
91 105
 				self.log(message.guild, f'New user {message.author.name} ' + \
92
-					f'({message.author.id}) posted URL. User banned.')
106
+					f'({message.author.id}) posted URL {join_age_str} after ' + \
107
+					'joining. User banned.')
93 108
 			bm = BotMessage(
94 109
 					message.guild,
95 110
 					f'User {message.author.mention} posted a URL ' + \
@@ -115,22 +130,28 @@ class URLSpamCog(BaseCog):
115 130
 			if not context.is_deleted:
116 131
 				await sm.delete()
117 132
 				context.is_deleted = True
118
-				self.log(sm.guild, f'URL spam by {sm.author.name} deleted by {reacted_by.name}')
133
+				self.log(sm.guild, f'URL spam by {sm.author.name} deleted ' + \
134
+					f'by {reacted_by.name}')
119 135
 		elif reaction.emoji == CONFIG['kick_emoji']:
120 136
 			if not context.is_deleted:
121 137
 				await sm.delete()
122 138
 				context.is_deleted = True
123 139
 			if not context.is_kicked:
124
-				await sm.author.kick(reason=f'Rocketbot: Kicked for URL spam by {reacted_by.name}')
140
+				await sm.author.kick(
141
+					reason=f'Rocketbot: Kicked for URL spam by {reacted_by.name}')
125 142
 				context.is_kicked = True
126
-				self.log(sm.guild, f'URL spammer {sm.author.name} kicked by {reacted_by.name}')
143
+				self.log(sm.guild, f'URL spammer {sm.author.name} kicked ' + \
144
+					f'by {reacted_by.name}')
127 145
 		elif reaction.emoji == CONFIG['ban_emoji']:
128 146
 			if not context.is_banned:
129
-				await sm.author.ban(reason=f'Rocketbot: Banned for URL spam by {reacted_by.name}', delete_message_days=1)
147
+				await sm.author.ban(
148
+					reason=f'Rocketbot: Banned for URL spam by {reacted_by.name}',
149
+					delete_message_days=1)
130 150
 				context.is_deleted = True
131 151
 				context.is_kicked = True
132 152
 				context.is_banned = True
133
-				self.log(sm.guild, f'URL spammer {sm.author.name} banned by {reacted_by.name}')
153
+				self.log(sm.guild, f'URL spammer {sm.author.name} banned ' + \
154
+					f'by {reacted_by.name}')
134 155
 		else:
135 156
 			return
136 157
 		await bot_message.set_reactions(BotMessageReaction.standard_set(

+ 4
- 2
config.py.sample Целия файл

@@ -1,6 +1,6 @@
1 1
 # Copy this file to config.py and fill in necessary values
2 2
 CONFIG = {
3
-	'__config_version': 2,
3
+	'__config_version': 3,
4 4
 	'client_token': 'token',
5 5
 	'command_prefix': '$rb_',
6 6
 	'kick_emoji': '👢',
@@ -13,9 +13,10 @@ CONFIG = {
13 13
 	'config_path': 'config/',
14 14
 	'cog_defaults': {
15 15
 		'CrossPostCog': {
16
+			'enabled': False,
16 17
 			'warncount': 3,
17 18
 			'bancount': 9999,
18
-			'minlength': 0,
19
+			'minlength': 1,
19 20
 			'timespan': 60,
20 21
 		},
21 22
 		'JoinRaidCog': {
@@ -24,6 +25,7 @@ CONFIG = {
24 25
 			'warning_seconds': 5,
25 26
 		},
26 27
 		'URLSpamCog': {
28
+			'enabled': False,
27 29
 			'joinage': 900,  # Should be > 600 due to Discord-imposed waiting period
28 30
 			'action': 'nothing',  # "nothing" | "modwarn" | "delete" | "kick" | "ban"
29 31
 		},

+ 7
- 2
rocketbot.py Целия файл

@@ -16,7 +16,7 @@ from cogs.joinraidcog import JoinRaidCog
16 16
 from cogs.patterncog import PatternCog
17 17
 from cogs.urlspamcog import URLSpamCog
18 18
 
19
-CURRENT_CONFIG_VERSION = 2
19
+CURRENT_CONFIG_VERSION = 3
20 20
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:
21 21
     raise RuntimeError('config.py format may be outdated. Review ' +
22 22
         'config.py.sample, update the "__config_version" field to ' +
@@ -30,11 +30,16 @@ intents = Intents.default()
30 30
 intents.messages = True
31 31
 intents.members = True # To get join/leave events
32 32
 bot = Rocketbot(command_prefix=CONFIG['command_prefix'], intents=intents)
33
+
34
+# Core
33 35
 bot.add_cog(GeneralCog(bot))
34 36
 bot.add_cog(ConfigCog(bot))
35
-bot.add_cog(JoinRaidCog(bot))
37
+
38
+# Optional
36 39
 bot.add_cog(CrossPostCog(bot))
40
+bot.add_cog(JoinRaidCog(bot))
37 41
 bot.add_cog(PatternCog(bot))
38 42
 bot.add_cog(URLSpamCog(bot))
43
+
39 44
 bot.run(CONFIG['client_token'], bot=True, reconnect=True)
40 45
 print('\nBot aborted')

Loading…
Отказ
Запис