Просмотр исходного кода

JoinRaidCog refactored. Cleanup to all cogs.

tags/1.0.1
Rocketsoup 4 лет назад
Родитель
Сommit
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
 class CogSetting:
280
 class CogSetting:
281
 	def __init__(self,
281
 	def __init__(self,
282
 			name: str,
282
 			name: str,
283
+			datatype,
283
 			brief: str = None,
284
 			brief: str = None,
284
 			description: str = None,
285
 			description: str = None,
285
 			usage: str = None,
286
 			usage: str = None,
287
 			max_value = None,
288
 			max_value = None,
288
 			enum_values: set = None):
289
 			enum_values: set = None):
289
 		self.name = name
290
 		self.name = name
291
+		self.datatype = datatype
290
 		self.brief = brief
292
 		self.brief = brief
291
 		self.description = description or ''  # XXX: Can't be None
293
 		self.description = description or ''  # XXX: Can't be None
292
 		self.usage = usage
294
 		self.usage = usage
328
 	def add_setting(self, setting: CogSetting) -> None:
330
 	def add_setting(self, setting: CogSetting) -> None:
329
 		"""
331
 		"""
330
 		Called by a subclass in __init__ to register a mod-configurable
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
 		self.settings.append(setting)
341
 		self.settings.append(setting)
336
 
342
 
370
 
376
 
371
 	def __set_up_setting_commands(self):
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
 		if self.are_settings_setup:
382
 		if self.are_settings_setup:
377
 			return
383
 			return
386
 				break
392
 				break
387
 
393
 
388
 		for setting in self.settings:
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
 	def __make_getter_setter_commands(self,
400
 	def __make_getter_setter_commands(self,
392
 			setting: CogSetting,
401
 			setting: CogSetting,
405
 		# 	async def getvar(self, context):
414
 		# 	async def getvar(self, context):
406
 		async def getter(self, context):
415
 		async def getter(self, context):
407
 			await self.__get_setting_command(context, setting)
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
 			await self.__set_setting_command(context, new_value, setting)
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
 		get_command = commands.Command(
438
 		get_command = commands.Command(
412
 			getter,
439
 			getter,
413
 			name=f'get{setting.name}',
440
 			name=f'get{setting.name}',
440
 			self.bot.add_command(get_command)
467
 			self.bot.add_command(get_command)
441
 			self.bot.add_command(set_command)
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
 	async def __set_setting_command(self, context, new_value, setting) -> None:
510
 	async def __set_setting_command(self, context, new_value, setting) -> None:
444
 		setting_name = setting.name
511
 		setting_name = setting.name
445
 		if context.command.parent:
512
 		if context.command.parent:
465
 		await context.message.reply(
532
 		await context.message.reply(
466
 			f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
533
 			f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
467
 			mention_author=False)
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
 	async def __get_setting_command(self, context, setting) -> None:
538
 	async def __get_setting_command(self, context, setting) -> None:
470
 		setting_name = setting.name
539
 		setting_name = setting.name
482
 				f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
551
 				f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
483
 				mention_author=False)
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
 	# Bot message handling
578
 	# Bot message handling
486
 
579
 
487
 	@classmethod
580
 	@classmethod

+ 8
- 7
cogs/configcog.py Просмотреть файл

24
 
24
 
25
 	@config.command(
25
 	@config.command(
26
 		name='setwarningchannel',
26
 		name='setwarningchannel',
27
-		brief='Sets the channel where mod warnings are posted',
27
+		brief='Sets where to post mod warnings',
28
 		description='Run this command in the channel where bot messages ' +
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
 	async def config_setwarningchannel(self, context: commands.Context) -> None:
34
 	async def config_setwarningchannel(self, context: commands.Context) -> None:
34
-		'Command handler'
35
 		guild: Guild = context.guild
35
 		guild: Guild = context.guild
36
 		channel: TextChannel = context.channel
36
 		channel: TextChannel = context.channel
37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
37
 		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
45
 		brief='Shows the channel where mod warnings are posted',
45
 		brief='Shows the channel where mod warnings are posted',
46
 	)
46
 	)
47
 	async def config_getwarningchannel(self, context: commands.Context) -> None:
47
 	async def config_getwarningchannel(self, context: commands.Context) -> None:
48
-		'Command handler'
49
 		guild: Guild = context.guild
48
 		guild: Guild = context.guild
50
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
49
 		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
51
 		if channel_id is None:
50
 		if channel_id is None:
67
 			'attention of certain users, be sure to specify a properly ' +
66
 			'attention of certain users, be sure to specify a properly ' +
68
 			'formed @ tag, not just the name of the user/role.'
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
 		guild: Guild = context.guild
72
 		guild: Guild = context.guild
72
 		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
73
 		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
73
 		if mention is None:
74
 		if mention is None:

+ 104
- 72
cogs/crosspostcog.py Просмотреть файл

1
-from discord import Guild, Member, Message, PartialEmoji
2
-from discord.ext import commands
3
 from datetime import datetime, timedelta
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
 from config import CONFIG
6
 from config import CONFIG
7
 from rscollections import AgeBoundList, SizeBoundDict
7
 from rscollections import AgeBoundList, SizeBoundDict
8
-from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
9
 from storage import Storage
8
 from storage import Storage
10
 
9
 
11
 class SpamContext:
10
 class SpamContext:
19
 		self.is_autobanned = False
18
 		self.is_autobanned = False
20
 		self.spam_messages = set()  # of Message
19
 		self.spam_messages = set()  # of Message
21
 		self.deleted_messages = set()  # of Message
20
 		self.deleted_messages = set()  # of Message
21
+		self.unique_channels = set()  # of TextChannel
22
 
22
 
23
 class CrossPostCog(BaseCog):
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
 		brief='number of messages to trigger a warning',
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
 		usage='<count:int>',
39
 		usage='<count:int>',
28
 		min_value=2)
40
 		min_value=2)
29
-	SETTING_BAN_COUNT = CogSetting('bancount',
41
+	SETTING_BAN_COUNT = CogSetting('bancount', int,
30
 		brief='number of messages to trigger a ban',
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
 		usage='<count:int>',
46
 		usage='<count:int>',
34
 		min_value=2)
47
 		min_value=2)
35
-	SETTING_MIN_LENGTH = CogSetting('minlength',
48
+	SETTING_MIN_LENGTH = CogSetting('minlength', int,
36
 		brief='minimum message length',
49
 		brief='minimum message length',
37
 		description='The minimum number of characters in a message to be ' + \
50
 		description='The minimum number of characters in a message to be ' + \
38
 			'checked for duplicates. This can help ignore common short ' + \
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
 		usage='<character_count:int>',
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
 		brief='time window to look for dupe messages',
56
 		brief='time window to look for dupe messages',
45
 		description='The number of seconds of message history to look at ' + \
57
 		description='The number of seconds of message history to look at ' + \
46
 			'when looking for duplicates. Shorter values are preferred, ' + \
58
 			'when looking for duplicates. Shorter values are preferred, ' + \
48
 		usage='<seconds:int>',
60
 		usage='<seconds:int>',
49
 		min_value=1)
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
 	def __init__(self, bot):
66
 	def __init__(self, bot):
60
 		super().__init__(bot)
67
 		super().__init__(bot)
68
+		self.add_setting(CrossPostCog.SETTING_ENABLED)
61
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
69
 		self.add_setting(CrossPostCog.SETTING_WARN_COUNT)
62
 		self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
70
 		self.add_setting(CrossPostCog.SETTING_BAN_COUNT)
63
 		self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
71
 		self.add_setting(CrossPostCog.SETTING_MIN_LENGTH)
64
 		self.add_setting(CrossPostCog.SETTING_TIMESPAN)
72
 		self.add_setting(CrossPostCog.SETTING_TIMESPAN)
65
 		self.max_spam_contexts = 12
73
 		self.max_spam_contexts = 12
66
 
74
 
67
-	# Config
68
-
69
 	async def __record_message(self, message: Message) -> None:
75
 	async def __record_message(self, message: Message) -> None:
70
 		if message.author.permissions_in(message.channel).ban_members:
76
 		if message.author.permissions_in(message.channel).ban_members:
71
 			# User exempt from spam detection
77
 			# User exempt from spam detection
74
 			# Message too short to count towards spam total
80
 			# Message too short to count towards spam total
75
 			return
81
 			return
76
 		max_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_TIMESPAN))
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
 		recent_messages.max_age = max_age
88
 		recent_messages.max_age = max_age
80
 		recent_messages.append(message)
89
 		recent_messages.append(message)
81
-		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
82
 
90
 
83
 		# Get all recent messages by user
91
 		# Get all recent messages by user
84
 		member_messages = [m for m in recent_messages if m.author.id == message.author.id]
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
 			return
94
 			return
87
 
95
 
88
 		# Look for repeats
96
 		# Look for repeats
89
-		hash_to_count = {}
97
+		hash_to_channels = {}  # int --> set(TextChannel)
90
 		max_count = 0
98
 		max_count = 0
91
 		for m in member_messages:
99
 		for m in member_messages:
92
 			key = hash(m.content)
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
 			return
108
 			return
98
 
109
 
99
 		# Handle the spam
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
 				continue
120
 				continue
106
 			key = f'{message.author.id}|{message_hash}'
121
 			key = f'{message.author.id}|{message_hash}'
107
 			context = spam_lookup.get(key)
122
 			context = spam_lookup.get(key)
110
 				context = SpamContext(message.author, message_hash)
125
 				context = SpamContext(message.author, message_hash)
111
 				spam_lookup[key] = context
126
 				spam_lookup[key] = context
112
 				context.age = message.created_at
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
 			for m in member_messages:
131
 			for m in member_messages:
114
 				if hash(m.content) == message_hash:
132
 				if hash(m.content) == message_hash:
115
 					context.spam_messages.add(m)
133
 					context.spam_messages.add(m)
134
+					context.unique_channels.add(m.channel)
116
 			await self.__update_from_context(context)
135
 			await self.__update_from_context(context)
117
 
136
 
118
 	async def __update_from_context(self, context: SpamContext):
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
 			if not context.is_banned:
141
 			if not context.is_banned:
121
 				count = len(context.spam_messages)
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
 				context.is_kicked = True
148
 				context.is_kicked = True
124
 				context.is_banned = True
149
 				context.is_banned = True
125
 				context.is_autobanned = True
150
 				context.is_autobanned = True
126
 				context.deleted_messages |= context.spam_messages
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
 			else:
156
 			else:
129
 				# Already banned. Nothing to update in the message.
157
 				# Already banned. Nothing to update in the message.
130
 				return
158
 				return
133
 	async def __update_message_from_context(self, context: SpamContext) -> None:
161
 	async def __update_message_from_context(self, context: SpamContext) -> None:
134
 		first_spam_message = next(iter(context.spam_messages))
162
 		first_spam_message = next(iter(context.spam_messages))
135
 		spam_count = len(context.spam_messages)
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
 		message = context.bot_message
166
 		message = context.bot_message
138
 		if message is None:
167
 		if message is None:
139
 			message = BotMessage(context.member.guild, '',
168
 			message = BotMessage(context.member.guild, '',
141
 			message.quote = first_spam_message.content
170
 			message.quote = first_spam_message.content
142
 		if context.is_autobanned:
171
 		if context.is_autobanned:
143
 			text = f'User {context.member.mention} auto banned for ' + \
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
 			await message.set_reactions([])
175
 			await message.set_reactions([])
147
 			await message.set_text(text)
176
 			await message.set_text(text)
148
 		else:
177
 		else:
149
 			await message.set_text(f'User {context.member.mention} posted ' +
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
 			await message.set_reactions(BotMessageReaction.standard_set(
180
 			await message.set_reactions(BotMessageReaction.standard_set(
152
 				did_delete = deleted_count >= spam_count,
181
 				did_delete = deleted_count >= spam_count,
153
 				message_count = spam_count,
182
 				message_count = spam_count,
157
 			await self.post_message(message)
186
 			await self.post_message(message)
158
 			context.bot_message = message
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
 	async def on_mod_react(self,
189
 	async def on_mod_react(self,
182
 			bot_message: BotMessage,
190
 			bot_message: BotMessage,
183
 			reaction: BotMessageReaction,
191
 			reaction: BotMessageReaction,
186
 		if context is None:
194
 		if context is None:
187
 			return
195
 			return
188
 
196
 
197
+		channel_count = len(context.unique_channels)
189
 		if reaction.emoji == CONFIG['trash_emoji']:
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
 		elif reaction.emoji == CONFIG['kick_emoji']:
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
 		elif reaction.emoji == CONFIG['ban_emoji']:
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
 	@commands.Cog.listener()
228
 	@commands.Cog.listener()
197
 	async def on_message(self, message: Message):
229
 	async def on_message(self, message: Message):
202
 				message.content is None or \
234
 				message.content is None or \
203
 				message.content == '':
235
 				message.content == '':
204
 			return
236
 			return
237
+		if not self.get_guild_setting(message.guild, self.SETTING_ENABLED):
238
+			return
205
 		await self.__record_message(message)
239
 		await self.__record_message(message)
206
 
240
 
207
-	# -- Commands -----------------------------------------------------------
208
-
209
 	@commands.group(
241
 	@commands.group(
210
-		brief='Manages crosspost/repeated post detection and handling',
242
+		brief='Manages crosspost detection and handling',
211
 	)
243
 	)
212
 	@commands.has_permissions(ban_members=True)
244
 	@commands.has_permissions(ban_members=True)
213
 	@commands.guild_only()
245
 	@commands.guild_only()
214
 	async def crosspost(self, context: commands.Context):
246
 	async def crosspost(self, context: commands.Context):
215
-		'Command group'
247
+		'Crosspost detection command group'
216
 		if context.invoked_subcommand is None:
248
 		if context.invoked_subcommand is None:
217
 			await context.send_help()
249
 			await context.send_help()

+ 128
- 472
cogs/joinraidcog.py Просмотреть файл

1
-from discord import Guild, Intents, Member, Message, PartialEmoji, RawReactionActionEvent
1
+from datetime import datetime, timedelta
2
+from discord import Guild, Member
2
 from discord.ext import commands
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
 class JoinRaidCog(BaseCog):
21
 class JoinRaidCog(BaseCog):
207
 	"""
22
 	"""
208
 	Cog for monitoring member joins and detecting potential bot raids.
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
 	def __init__(self, bot):
46
 	def __init__(self, bot):
217
 		super().__init__(bot)
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
 	@commands.group(
52
 	@commands.group(
242
 		brief='Manages join raid detection and handling',
53
 		brief='Manages join raid detection and handling',
248
 		if context.invoked_subcommand is None:
59
 		if context.invoked_subcommand is None:
249
 			await context.send_help()
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
 	@commands.Cog.listener()
86
 	@commands.Cog.listener()
375
 	async def on_member_join(self, member: Member) -> None:
87
 	async def on_member_join(self, member: Member) -> None:
376
 		'Event handler'
88
 		'Event handler'
377
 		guild: Guild = member.guild
89
 		guild: Guild = member.guild
378
-		if not self.__is_enabled(guild):
90
+		if not self.get_guild_setting(guild, self.SETTING_ENABLED):
379
 			return
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
 		else:
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
 			return
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
 			else:
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
 					await message.author.kick(reason='Rocketbot: Message matched banned pattern')
96
 					await message.author.kick(reason='Rocketbot: Message matched banned pattern')
97
 					text = f'Message from {message.author.mention} matched ' + \
97
 					text = f'Message from {message.author.mention} matched ' + \
98
 						'banned pattern. Message deleted and user kicked.'
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
 				elif pattern.action == 'ban':
102
 				elif pattern.action == 'ban':
101
 					await message.delete()
103
 					await message.delete()
102
 					await message.author.ban(reason='Rocketbot: Message matched banned pattern')
104
 					await message.author.ban(reason='Rocketbot: Message matched banned pattern')
103
 					text = f'Message from {message.author.mention} matched ' + \
105
 					text = f'Message from {message.author.mention} matched ' + \
104
 						'banned pattern. Message deleted and user banned.'
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
 				if text:
110
 				if text:
107
 					m = BotMessage(message.guild,
111
 					m = BotMessage(message.guild,
108
 						text = msg,
112
 						text = msg,
143
 			string
147
 			string
144
 			regex
148
 			regex
145
 			mention
149
 			mention
150
+
151
+		Evaluation
152
+			and
153
+			or
154
+			( )
155
+			!( )
146
 	"""
156
 	"""
147
 
157
 
148
 	@commands.group(
158
 	@commands.group(

+ 37
- 16
cogs/urlspamcog.py Просмотреть файл

15
 		self.is_banned = False
15
 		self.is_banned = False
16
 
16
 
17
 class URLSpamCog(BaseCog):
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
 			brief='action to take on spam',
27
 			brief='action to take on spam',
20
 			description='The action to take on detected URL spam.',
28
 			description='The action to take on detected URL spam.',
21
 			enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
29
 			enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
22
-	SETTING_JOIN_AGE = CogSetting('joinage',
30
+	SETTING_JOIN_AGE = CogSetting('joinage', float,
23
 			brief='seconds since member joined',
31
 			brief='seconds since member joined',
24
 			description='The minimum seconds since the user joined the ' + \
32
 			description='The minimum seconds since the user joined the ' + \
25
 				'server before they can post URLs. URLs posted by users ' + \
33
 				'server before they can post URLs. URLs posted by users ' + \
32
 
40
 
33
 	def __init__(self, bot):
41
 	def __init__(self, bot):
34
 		super().__init__(bot)
42
 		super().__init__(bot)
43
+		self.add_setting(URLSpamCog.SETTING_ENABLED)
35
 		self.add_setting(URLSpamCog.SETTING_ACTION)
44
 		self.add_setting(URLSpamCog.SETTING_ACTION)
36
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
45
 		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
37
 
46
 
55
 			return
64
 			return
56
 
65
 
57
 		action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
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
 		if action == 'nothing':
69
 		if action == 'nothing':
60
 			return
70
 			return
61
 		if not self.__contains_url(message.content):
71
 		if not self.__contains_url(message.content):
68
 			if action == 'modwarn':
78
 			if action == 'modwarn':
69
 				needs_attention = True
79
 				needs_attention = True
70
 				self.log(message.guild, f'New user {message.author.name} ' + \
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
 			elif action == 'delete':
83
 			elif action == 'delete':
73
 				await message.delete()
84
 				await message.delete()
74
 				context.is_deleted = True
85
 				context.is_deleted = True
75
 				self.log(message.guild, f'New user {message.author.name} ' + \
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
 			elif action == 'kick':
89
 			elif action == 'kick':
78
 				await message.delete()
90
 				await message.delete()
79
 				context.is_deleted = True
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
 				context.is_kicked = True
94
 				context.is_kicked = True
83
 				self.log(message.guild, f'New user {message.author.name} ' + \
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
 			elif action == 'ban':
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
 				context.is_deleted = True
102
 				context.is_deleted = True
89
 				context.is_kicked = True
103
 				context.is_kicked = True
90
 				context.is_banned = True
104
 				context.is_banned = True
91
 				self.log(message.guild, f'New user {message.author.name} ' + \
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
 			bm = BotMessage(
108
 			bm = BotMessage(
94
 					message.guild,
109
 					message.guild,
95
 					f'User {message.author.mention} posted a URL ' + \
110
 					f'User {message.author.mention} posted a URL ' + \
115
 			if not context.is_deleted:
130
 			if not context.is_deleted:
116
 				await sm.delete()
131
 				await sm.delete()
117
 				context.is_deleted = True
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
 		elif reaction.emoji == CONFIG['kick_emoji']:
135
 		elif reaction.emoji == CONFIG['kick_emoji']:
120
 			if not context.is_deleted:
136
 			if not context.is_deleted:
121
 				await sm.delete()
137
 				await sm.delete()
122
 				context.is_deleted = True
138
 				context.is_deleted = True
123
 			if not context.is_kicked:
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
 				context.is_kicked = True
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
 		elif reaction.emoji == CONFIG['ban_emoji']:
145
 		elif reaction.emoji == CONFIG['ban_emoji']:
128
 			if not context.is_banned:
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
 				context.is_deleted = True
150
 				context.is_deleted = True
131
 				context.is_kicked = True
151
 				context.is_kicked = True
132
 				context.is_banned = True
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
 		else:
155
 		else:
135
 			return
156
 			return
136
 		await bot_message.set_reactions(BotMessageReaction.standard_set(
157
 		await bot_message.set_reactions(BotMessageReaction.standard_set(

+ 4
- 2
config.py.sample Просмотреть файл

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

+ 7
- 2
rocketbot.py Просмотреть файл

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

Загрузка…
Отмена
Сохранить